diff --git a/.appveyor.yml b/.appveyor.yml deleted file mode 100644 index edc5ecbae89d..000000000000 --- a/.appveyor.yml +++ /dev/null @@ -1,24 +0,0 @@ -environment: - nodejs_version: "10.9.0" # Same version as used in CircleCI. - -matrix: - fast_finish: true - -install: - - ps: Install-Product node $env:nodejs_version - # --network-timeout is a workaround for https://github.com/yarnpkg/yarn/issues/6221 - - yarn --frozen-lockfile --network-timeout=500000 - - yarn webdriver-update-appveyor - -test_script: - - node --version - - yarn --version - - yarn test - - node tests\legacy-cli\run_e2e.js --appveyor "--glob=tests/{basic,commands,generate,build/styles}/**" - -build: off -deploy: off - -cache: - - node_modules -> yarn.lock - - "%LOCALAPPDATA%\\Yarn" diff --git a/.bazelrc b/.bazelrc new file mode 100644 index 000000000000..a02316a9aa50 --- /dev/null +++ b/.bazelrc @@ -0,0 +1,181 @@ +# Disable NG CLI TTY mode +build --action_env=NG_FORCE_TTY=false + +# Required by `rules_ts`. +common --@aspect_rules_ts//ts:skipLibCheck=always +common --@aspect_rules_ts//ts:default_to_tsc_transpiler + +# Make TypeScript compilation fast, by keeping a few copies of the compiler +# running as daemons, and cache SourceFile AST's to reduce parse time. +build --strategy=TypeScriptCompile=worker + +# Enable debugging tests with --config=debug +test:debug --test_arg=--node_options=--inspect-brk --test_output=streamed --test_strategy=exclusive --test_timeout=9999 --nocache_test_results + +# Enable debugging tests with --config=no-sharding +# The below is useful to while using `fit` and `fdescribe` to avoid sharing and re-runs of failed flaky tests. +test:no-sharding --flaky_test_attempts=1 --test_sharding_strategy=disabled + +# Frozen lockfile +common --lockfile_mode=error + +############################### +# Filesystem interactions # +############################### + +# Create symlinks in the project: +# - dist/bin for outputs +# - dist/testlogs, dist/genfiles +# - bazel-out +# NB: bazel-out should be excluded from the editor configuration. +# The checked-in /.vscode/settings.json does this for VSCode. +# Other editors may require manual config to ignore this directory. +# In the past, we saw a problem where VSCode traversed a massive tree, opening file handles and +# eventually a surprising failure with auto-discovery of the C++ toolchain in +# MacOS High Sierra. +# See https://github.com/bazelbuild/bazel/issues/4603 +build --symlink_prefix=dist/ + +# Turn off legacy external runfiles +build --nolegacy_external_runfiles + +# Turn on --incompatible_strict_action_env which was on by default +# in Bazel 0.21.0 but turned off again in 0.22.0. Follow +# https://github.com/bazelbuild/bazel/issues/7026 for more details. +# This flag is needed to so that the bazel cache is not invalidated +# when running bazel via `pnpm bazel`. +# See https://github.com/angular/angular/issues/27514. +build --incompatible_strict_action_env +run --incompatible_strict_action_env +test --incompatible_strict_action_env + +# Enable remote caching of build/action tree +build --experimental_remote_merkle_tree_cache + +# Ensure that tags applied in BUILDs propagate to actions +common --incompatible_allow_tags_propagation + +# Ensure sandboxing is enabled even for exclusive tests +test --incompatible_exclusive_test_sandboxed + +############################### +# Saucelabs support # +# Turn on these settings with # +# --config=saucelabs # +############################### + +# Expose SauceLabs environment to actions +# These environment variables are needed by +# web_test_karma to run on Saucelabs +test:saucelabs --action_env=SAUCE_USERNAME +test:saucelabs --action_env=SAUCE_ACCESS_KEY +test:saucelabs --action_env=SAUCE_READY_FILE +test:saucelabs --action_env=SAUCE_PID_FILE +test:saucelabs --action_env=SAUCE_TUNNEL_IDENTIFIER +test:saucelabs --define=KARMA_WEB_TEST_MODE=SL_REQUIRED + +############################### +# Release support # +# Turn on these settings with # +# --config=release # +############################### + +# Releases should always be stamped with version control info +# This command assumes node on the path and is a workaround for +# https://github.com/bazelbuild/bazel/issues/4802 +build:release --workspace_status_command="pnpm --silent ng-dev release build-env-stamp --mode=release" +build:release --stamp + +build:snapshot --workspace_status_command="pnpm --silent ng-dev release build-env-stamp --mode=snapshot" +build:snapshot --stamp +build:snapshot --//:enable_snapshot_repo_deps + +build:e2e --workspace_status_command="pnpm --silent ng-dev release build-env-stamp --mode=release" +build:e2e --stamp +test:e2e --test_timeout=3600 --experimental_ui_max_stdouterr_bytes=2097152 + +# Retry in the event of flakes +test:e2e --flaky_test_attempts=2 + +build:local --//:enable_package_json_tar_deps + +############################### +# Output # +############################### + +# A more useful default output mode for bazel query +# Prints eg. "ng_module rule //foo:bar" rather than just "//foo:bar" +query --output=label_kind + +# By default, failing tests don't print any output, it goes to the log file +test --test_output=errors +################################ +# Remote Execution Setup # +################################ + +# Use the Angular team internal GCP instance for remote execution. +build:remote --remote_instance_name=projects/internal-200822/instances/primary_instance +build:remote --bes_instance_name=internal-200822 + +# Starting with Bazel 0.27.0 strategies do not need to be explicitly +# defined. See https://github.com/bazelbuild/bazel/issues/7480 +build:remote --define=EXECUTOR=remote + +# Setup the remote build execution servers. +build:remote --remote_cache=remotebuildexecution.googleapis.com +build:remote --remote_executor=remotebuildexecution.googleapis.com +build:remote --remote_timeout=600 +build:remote --jobs=150 + +# Setup the toolchain and platform for the remote build execution. The platform +# is provided by the shared dev-infra package and targets k8 remote containers. +build:remote --extra_execution_platforms=@devinfra//bazel/remote-execution:platform_with_network +build:remote --host_platform=@devinfra//bazel/remote-execution:platform_with_network +build:remote --platforms=@devinfra//bazel/remote-execution:platform_with_network + +# Set remote caching settings +build:remote --remote_accept_cached=true +build:remote --remote_upload_local_results=false + +# Force remote executions to consider the entire run as linux. +# This is required for OSX cross-platform RBE. +build:remote --cpu=k8 +build:remote --host_cpu=k8 + +# Set up authentication mechanism for RBE +build:remote --google_default_credentials + +# Use HTTP remote cache +build:remote-cache --remote_cache=https://storage.googleapis.com/angular-team-cache +build:remote-cache --remote_accept_cached=true +build:remote-cache --remote_upload_local_results=false +build:remote-cache --google_default_credentials + +# Additional flags added when running a "trusted build" with additional access +build:trusted-build --remote_upload_local_results=true + +# Fixes issues with browser archives and files with spaces. Could be +# removed in Bazel 8 when Bazel runfiles supports spaces. +build --experimental_inprocess_symlink_creation + +#################################################### +# rules_js specific flags +#################################################### + +# TODO(josephperrott): investigate if this can be removed eventually. +# Prevents the npm package extract from occuring or caching on RBE which overwhelms our quota +build --modify_execution_info=NpmPackageExtract=+no-remote + +# Allow the Bazel server to check directory sources for changes. `rules_js` previously +# heavily relied on this, but still uses directory "inputs" in some cases. +# See: https://github.com/aspect-build/rules_js/issues/1408. +startup --host_jvm_args=-DBAZEL_TRACK_SOURCE_DIRECTORIES=1 + +#################################################### +# User bazel configuration +# NOTE: This needs to be the *last* entry in the config. +#################################################### + +# Load any settings which are specific to the current user. Needs to be *last* statement +# in this config, as the user configuration should be able to overwrite flags from this file. +try-import .bazelrc.user diff --git a/.bazelversion b/.bazelversion new file mode 100644 index 000000000000..11f1d47dac93 --- /dev/null +++ b/.bazelversion @@ -0,0 +1 @@ +8.8.2 diff --git a/.circleci/bazel.rc b/.circleci/bazel.rc deleted file mode 100644 index 0cf9444d5d72..000000000000 --- a/.circleci/bazel.rc +++ /dev/null @@ -1,20 +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 - -# Don't run manual tests -test --test_tag_filters=-manual - -# 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 "medium" class which is the default: -# https://circleci.com/docs/2.0/configuration-reference/#resource_class -build --local_resources=3072,2.0,1.0 - -# Retry in the event of flakes -test --flaky_test_attempts=2 diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 1f24b8304721..000000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,211 +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/ - -# Variables - -## IMPORTANT -# If you change the `docker_image` version, also change the `cache_key` suffix and the version of -# `com_github_bazelbuild_buildtools` in the `/WORKSPACE` file. -var_1: &docker_image angular/ngcontainer:0.5.0 -var_2: &cache_key angular_devkit-{{ checksum "yarn.lock" }}-0.5.0 -var_3: &node_8_docker_image angular/ngcontainer:0.3.3 - -# Settings common to each job -anchor_1: &defaults - working_directory: ~/ng - docker: - - image: *docker_image - -# After checkout, rebase on top of master. -# Similar to travis behavior, but not quite the same. -# See https://discuss.circleci.com/t/1662 -anchor_2: &post_checkout - post: git pull --ff-only origin "refs/pull/${CI_PULL_REQUEST//*pull\//}/merge" -anchor_3: &root_package_lock_key - key: *cache_key -anchor_4: &attach_options - at: . - -# Job definitions -version: 2 -jobs: - install: - <<: *defaults - steps: - - checkout: *post_checkout - - restore_cache: *root_package_lock_key - - run: yarn install --frozen-lockfile - - persist_to_workspace: - root: . - paths: - - ./* - - save_cache: - <<: *root_package_lock_key - paths: - - ~/.cache/yarn - - lint: - <<: *defaults - steps: - - attach_workspace: *attach_options - - run: npm run lint - - validate: - <<: *defaults - steps: - - attach_workspace: *attach_options - - run: npm run validate -- --ci - - test: - <<: *defaults - steps: - - attach_workspace: *attach_options - - run: npm run test -- --full - - test-large: - <<: *defaults - resource_class: large - parallelism: 4 - steps: - - attach_workspace: *attach_options - - run: npm run webdriver-update-circleci - - run: npm run test-large -- --full --nb-shards=${CIRCLE_NODE_TOTAL} --shard=${CIRCLE_NODE_INDEX} - - e2e-cli: - <<: *defaults - environment: - BASH_ENV: ~/.profile - resource_class: xlarge - parallelism: 4 - steps: - - attach_workspace: *attach_options - - run: xvfb-run -a node ./tests/legacy-cli/run_e2e --nb-shards=${CIRCLE_NODE_TOTAL} --shard=${CIRCLE_NODE_INDEX} - - store_artifacts: - path: /tmp/dist - destination: cli/new-production - - e2e-node-8: - <<: *defaults - # Overwrite docker image to node 8. - docker: - - image: *node_8_docker_image - environment: - BASH_ENV: ~/.profile - resource_class: xlarge - parallelism: 2 - steps: - - attach_workspace: *attach_options - - run: npm install --global npm@6 - - run: xvfb-run -a node ./tests/legacy-cli/run_e2e --glob=tests/basic/* --nb-shards=${CIRCLE_NODE_TOTAL} --shard=${CIRCLE_NODE_INDEX} - - e2e-cli-ng-snapshots: - <<: *defaults - environment: - BASH_ENV: ~/.profile - resource_class: xlarge - parallelism: 4 - steps: - - attach_workspace: *attach_options - - run: xvfb-run -a node ./tests/legacy-cli/run_e2e --nb-shards=${CIRCLE_NODE_TOTAL} --shard=${CIRCLE_NODE_INDEX} --ng-snapshots - - build: - <<: *defaults - steps: - - attach_workspace: *attach_options - - run: npm run admin -- build - - build-bazel: - <<: *defaults - resource_class: xlarge - steps: - - attach_workspace: *attach_options - - run: sudo cp .circleci/bazel.rc /etc/bazel.bazelrc - - run: bazel test //packages/... - - snapshot_publish: - <<: *defaults - steps: - - attach_workspace: *attach_options - - run: - name: Decrypt Credentials - command: | - openssl aes-256-cbc -d -in .circleci/github_token -k "${KEY}" -out ~/github_token - - run: - name: Deployment to Snapshot - command: | - npm run admin -- snapshots --verbose --githubTokenFile=${HOME}/github_token - - publish: - <<: *defaults - steps: - - attach_workspace: *attach_options - - run: - name: Decrypt Credentials - command: | - openssl aes-256-cbc -d -in .circleci/npm_token -k "${KEY}" -out ~/.npmrc - - run: - name: Deployment to NPM - command: | - npm run admin -- publish --verbose - -workflows: - version: 2 - default_workflow: - jobs: - - install - - lint: - requires: - - install - - validate: - requires: - - install - - build: - requires: - - install - - build-bazel: - requires: - - build - - test: - requires: - - build - - test-large: - requires: - - build - - e2e-cli: - requires: - - build - - e2e-node-8: - requires: - - build - - e2e-cli-ng-snapshots: - requires: - - build - filters: - branches: - only: master - - snapshot_publish: - requires: - - test - - build - - e2e-cli - filters: - branches: - ignore: - - /pull\/.*/ - - publish: - requires: - - test - - build - - e2e-cli - - snapshot_publish - filters: - tags: - only: /^v\d+/ - branches: - ignore: /.*/ diff --git a/.circleci/github_token b/.circleci/github_token deleted file mode 100644 index 450cb2c93f8c..000000000000 --- a/.circleci/github_token +++ /dev/null @@ -1 +0,0 @@ -Salted__zÈùº¬ö"Bõ¾Y¾’|‚ۍ¢V”QֳUzWò±/G…îR ¡e}j‘% þÿ¦<%öáÉÿ–¼ \ No newline at end of file diff --git a/.circleci/npm_token b/.circleci/npm_token deleted file mode 100644 index 5a1bb6303052..000000000000 --- a/.circleci/npm_token +++ /dev/null @@ -1 +0,0 @@ -Salted__°/¥ÙL Ž¡”ö¦°Â;ª…(.|©í¡ –ÚCŒàԒ�“þÖ5`h¢8Ðài8JÁo*´?}çû£3§0f´!Ð'ÛB¸̠œ"UƊ&K!¨%Áɵڤ \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index 36e19acf952e..c0a70d2acb14 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# http://editorconfig.org +# https://editorconfig.org root = true @@ -9,6 +9,7 @@ indent_size = 2 insert_final_newline = true spaces_around_brackets = inside trim_trailing_whitespace = true +quote_type = single [*.md] insert_final_newline = false diff --git a/.gemini/config.yaml b/.gemini/config.yaml new file mode 100644 index 000000000000..795367b13a53 --- /dev/null +++ b/.gemini/config.yaml @@ -0,0 +1,13 @@ +have_fun: false +code_review: + disable: false + comment_severity_threshold: MEDIUM + max_review_comments: -1 + pull_request_opened: + help: false + summary: false + code_review: true + include_drafts: false +ignore_patterns: + - pnpm-lock.yaml + - CHANGELOG.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000000..de32b85d6693 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# JS and TS files must always use LF for tools to work +*.js eol=lf +*.ts eol=lf +*.json eol=lf +*.css eol=lf +*.scss eol=lf +*.less eol=lf +*.html eol=lf +*.svg eol=lf diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 524740f30080..000000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,56 +0,0 @@ - -### Bug Report or Feature Request (mark with an `x`) -``` -- [ ] bug report -> please search issues before submitting -- [ ] feature request -``` - -### Command (mark with an `x`) -``` -- [ ] new -- [ ] build -- [ ] serve -- [ ] test -- [ ] e2e -- [ ] generate -- [ ] add -- [ ] update -- [ ] lint -- [ ] xi18n -- [ ] run -- [ ] config -- [ ] help -- [ ] version -- [ ] doc -``` - -### Versions - - - -### Repro steps - - - -### The log given by the failure - - - -### Desired functionality - - - -### Mention any other details that might be useful - diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.yml b/.github/ISSUE_TEMPLATE/1-bug-report.yml new file mode 100644 index 000000000000..5c4ea7d2cbdb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug-report.yml @@ -0,0 +1,102 @@ +name: Bug report +description: Report a bug in Angular CLI +body: + - type: markdown + attributes: + value: | + Oh hi there! + + To expedite issue processing please search open and closed issues before submitting a new one. + Existing issues often contain information about workarounds, resolution, or progress updates. + - type: dropdown + id: command + attributes: + label: Command + description: Can you pin-point the command or commands that are effected by this bug? + options: + - add + - build + - config + - doc + - e2e + - extract-i18n + - generate + - help + - lint + - new + - other + - run + - serve + - test + - update + - version + multiple: true + validations: + required: true + - type: checkboxes + id: is-regression + attributes: + label: Is this a regression? + description: Did this behavior use to work in the previous version? + options: + - label: Yes, this behavior used to work in the previous version + - type: input + id: version-bug-was-not-present + attributes: + label: The previous version in which this bug was not present was + validations: + required: false + - type: textarea + id: description + attributes: + label: Description + description: A clear and concise description of the problem. + validations: + required: true + - type: textarea + id: minimal-reproduction + attributes: + label: Minimal Reproduction + description: | + Simple steps to reproduce this bug. + + **Please include:** + * commands run (including args) + * packages added + * related code changes + + + If reproduction steps are not enough for reproduction of your issue, please create a minimal GitHub repository with the reproduction of the issue. + A good way to make a minimal reproduction is to create a new app via `ng new repro-app` and add the minimum possible code to show the problem. + Share the link to the repo below along with step-by-step instructions to reproduce the problem, as well as expected and actual behavior. + + Issues that don't have enough info and can't be reproduced will be closed. + + You can read more about issue submission guidelines [here](https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#-submitting-an-issue). + validations: + required: true + - type: textarea + id: exception-or-error + attributes: + label: Exception or Error + description: If the issue is accompanied by an exception or an error, please share it below. + render: text + validations: + required: false + - type: textarea + id: environment + attributes: + label: Your Environment + description: Run `ng version` and paste output below. + render: text + validations: + required: true + - type: textarea + id: other + attributes: + label: Anything else relevant? + description: | + Is this a browser specific issue? If so, please specify the browser and version. + Do any of these matter: operating system, IDE, package manager, HTTP server, ...? If so, please mention it below. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/2-feature-request.yml b/.github/ISSUE_TEMPLATE/2-feature-request.yml new file mode 100644 index 000000000000..4a01698e0f37 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-feature-request.yml @@ -0,0 +1,55 @@ +name: Feature request +description: Suggest a feature for Angular CLI +body: + - type: markdown + attributes: + value: | + Oh hi there! + + To expedite issue processing please search open and closed issues before submitting a new one. + Existing issues often contain information about workarounds, resolution, or progress updates. + - type: dropdown + id: command + attributes: + label: Command + description: Can you pin-point the command or commands that are relevant for this feature request? + options: + - add + - build + - config + - doc + - e2e + - extract-i18n + - generate + - help + - lint + - new + - run + - serve + - test + - update + - version + multiple: true + validations: + required: true + - type: textarea + id: description + attributes: + label: Description + description: A clear and concise description of the problem or missing capability. + validations: + required: true + - type: textarea + id: desired-solution + attributes: + label: Describe the solution you'd like + description: If you have a solution in mind, please describe it. + validations: + required: false + - type: textarea + id: alternatives + attributes: + label: Describe alternatives you've considered + description: Have you considered any alternative solutions or workarounds? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000000..898698af3906 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,17 @@ +blank_issues_enabled: false +contact_links: + - name: Docs or angular.dev issue report + url: https://github.com/angular/angular/issues/new + about: Report an issue in Angular's documentation or angular.dev application + - name: Security issue disclosure + url: https://angular.dev/best-practices/security + about: Report a security issue in Angular Framework, Material, or CLI + - name: Support request + url: https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#question + about: Questions and requests for support. + - name: Angular Framework + url: https://github.com/angular/angular/issues/new/choose + about: Issues and feature requests for Angular Framework + - name: Angular Material + url: https://github.com/angular/components/issues/new/choose + about: Issues and feature requests for Angular Material diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000000..3214dde0a4f4 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,43 @@ +## PR Checklist + +Please check to confirm your PR fulfills the following requirements: + + + +- [ ] The commit message follows our guidelines: https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#-commit-message-guidelines +- [ ] Tests for the changes have been added (for bug fixes / features) +- [ ] Docs have been added / updated (for bug fixes / features) + +## PR Type + +What kind of change does this PR introduce? + + + +- [ ] Bugfix +- [ ] Feature +- [ ] Code style update (formatting, local variables) +- [ ] Refactoring (no functional changes, no api changes) +- [ ] Build related changes +- [ ] CI related changes +- [ ] Documentation content changes +- [ ] Other... Please describe: + +## What is the current behavior? + + + +Issue Number: N/A + +## What is the new behavior? + + + +## Does this PR introduce a breaking change? + +- [ ] Yes +- [ ] No + + + +## Other information diff --git a/.github/SAVED_REPLIES.md b/.github/SAVED_REPLIES.md index 29e19832903c..dfce61802c0e 100644 --- a/.github/SAVED_REPLIES.md +++ b/.github/SAVED_REPLIES.md @@ -4,87 +4,87 @@ The following are canned responses that the Angular CLI team should use to close Since GitHub currently doesn't allow us to have a repository-wide or organization-wide list of [saved replies](https://help.github.com/articles/working-with-saved-replies/), these replies need to be maintained by individual team members. Since the responses can be modified in the future, all responses are versioned to simplify the process of keeping the responses up to date. - ## Angular CLI: Already Fixed (v1) + ``` Thanks for reporting this issue. Luckily, it has already been fixed in one of the recent releases. Please update to the most recent version to resolve the problem. If the problem persists in your application after upgrading, please open a new issue, provide a simple repository reproducing the problem, and describe the difference between the expected and current behavior. You can use `ng new repro-app` to create a new project where you reproduce the problem. ``` - ## Angular CLI: Don't Understand (v1) + ``` I'm sorry, but we don't understand the problem you are reporting. If the problem persists, please open a new issue, provide a simple repository reproducing the problem, and describe the difference between the expected and current behavior. You can use `ng new repro-app` to create a new project where you reproduce the problem. ``` - ## Angular CLI: Duplicate (v1.1) + ``` Thanks for reporting this issue. However, this issue is a duplicate of #. Please subscribe to that issue for future updates. ``` - ## Angular CLI: Insufficient Information Provided (v1) + ``` -Thanks for reporting this issue. However, you didn't provide sufficient information for us to understand and reproduce the problem. Please check out [our submission guidelines](https://github.com/angular/angular-cli/blob/master/CONTRIBUTING.md#-submitting-an-issue) to understand why we can't act on issues that are lacking important information. +Thanks for reporting this issue. However, you didn't provide sufficient information for us to understand and reproduce the problem. Please check out [our submission guidelines](https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#-submitting-an-issue) to understand why we can't act on issues that are lacking important information. If the problem persists, please file a new issue and ensure you provide all of the required information when filling out the issue template. ``` - ## Angular CLI: NPM install issue (v1) + ``` This seems like a problem with your node/npm and not with Angular CLI. Please have a look at the [fixing npm permissions page](https://docs.npmjs.com/getting-started/fixing-npm-permissions), [common errors page](https://docs.npmjs.com/troubleshooting/common-errors), [npm issue tracker](https://github.com/npm/npm/issues), or open a new issue if the problem you are experiencing isn't known. ``` - ## Angular CLI: Issue Outside of Angular CLI (v1.1) + ``` I'm sorry, but this issue is not caused by Angular CLI. Please contact the author(s) of the project or file an issue on their issue tracker. ``` - ## Angular CLI: Non-reproducible (v1) + ``` I'm sorry, but we can't reproduce the problem following the instructions you provided. Remember that we have a large number of issues to resolve, and have only a limited amount of time to reproduce your issue. Short, explicit instructions make it much more likely we'll be able to reproduce the problem so we can fix it. -If the problem persists, please open a new issue following [our submission guidelines](https://github.com/angular/angular-cli/blob/master/CONTRIBUTING.md#-submitting-an-issue). +If the problem persists, please open a new issue following [our submission guidelines](https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#-submitting-an-issue). A good way to make a minimal repro is to create a new app via `ng new repro-app` and adding the minimum possible code to show the problem. Then you can push this repository to github and link it here. ``` - ## Angular CLI: Obsolete (v1) + ``` Thanks for reporting this issue. This issue is now obsolete due to changes in the recent releases. Please update to the most recent Angular CLI version. If the problem persists after upgrading, please open a new issue, provide a simple repository reproducing the problem, and describe the difference between the expected and current behavior. ``` - ## Angular CLI: Support Request (v1) -``` -Hello, we reviewed this issue and determined that it doesn't fall into the bug report or feature request category. This issue tracker is not suitable for support requests, please repost your issue on [StackOverflow](http://stackoverflow.com/) using tag `angular-cli`. -If you are wondering why we don't resolve support issues via the issue tracker, please [check out this explanation](https://github.com/angular/angular-cli/blob/master/CONTRIBUTING.md#-got-a-question-or-problem). ``` +Hello, we reviewed this issue and determined that it doesn't fall into the bug report or feature request category. This issue tracker is not suitable for support requests, please repost your issue on [StackOverflow](https://stackoverflow.com/) using tag `angular-cli`. +If you are wondering why we don't resolve support issues via the issue tracker, please [check out this explanation](https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#-got-a-question-or-problem). +``` ## Angular CLI: Static Analysis errors (v1) + ``` Hello, errors like `Error encountered resolving symbol values statically` mean that there has been some problem in statically analyzing your app. Angular CLI always runs *some* 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 @@ -93,8 +93,19 @@ In that case, please open an issue in https://github.com/angular/angular. ``` ## Angular CLI: Lockfiles (v1) + ``` I'd like to remind everyone that **you only have reproducible installs if you use a lockfile**. Both [NPM v5+](https://docs.npmjs.com/files/package-locks) and [Yarn](https://yarnpkg.com/lang/en/docs/yarn-lock/) support lockfiles. If your CI works one day but not the next and you did not change your code or `package.json`, it is likely because one of your dependencies had a bad release and you did not have a lockfile. **It is your responsibility as a library consumer to use lockfiles**. No one wants to do a release with bugs but it sometimes happens, and the best we can do is to fix it as fast as possible with a new release. When you have a couple of thousand total dependencies it is only a matter of time until one of them has a bad release. ``` + +## Angular CLI: Spam (v1) + +``` +Woah, looks like you've opened a lot of issues/PRs recently. While we appreciate contributions from the community, triaging and reviewing a large influx of content in a short time period takes time away from other ongoing projects. As a result, we're closing these issues/PRs to maintain the team's focus. + +Note that this is not necessarily a rejection of the goals or direction of any of these contributions in particular, so much as a reflection of the team's current capacity and priorities. + +You are welcome to open a smaller subset of issues/PRs in accordance with [our policy](https://github.com/angular/angular/blob/main/contributing-docs/spam.md) focused on the most important and impactful contributions and we will do our best to prioritize a response as soon as possible. +``` diff --git a/.github/_CODEOWNERS.tmp b/.github/_CODEOWNERS.tmp deleted file mode 100644 index 3e79d395914f..000000000000 --- a/.github/_CODEOWNERS.tmp +++ /dev/null @@ -1,17 +0,0 @@ -/packages/_/ @hansl @clydin -/packages/angular/pwa @hansl @Brocco -/packages/angular_devkit/architect/ @filipesilva @hansl -/packages/angular_devkit/architect_cli/ @filipesilva @hansl -/packages/angular_devkit/build_angular/ @filipesilva @clydin -/packages/angular_devkit/build_ng_packagr/ @filipesilva @clydin -/packages/angular_devkit/build_optimizer/ @filipesilva @clydin -/packages/angular_devkit/core/ @hansl @clydin -/packages/angular_devkit/schematics/ @hansl @Brocco -/packages/angular_devkit/schematics_cli/ @hansl @Brocco -/packages/ngtools/webpack/ @hansl @filipesilva @clydin -/packages/schematics/angular/ @hansl @Brocco -/packages/schematics/package_update/ @hansl @Brocco -/packages/schematics/schematics/ @hansl @Brocco -/packages/schematics/update/ @hansl @Brocco -/rules/ @hansl @clydin -/scripts/ @hansl @clydin diff --git a/.github/angular-robot.yml b/.github/angular-robot.yml index 5e6b231c9d62..3e3a56a25603 100644 --- a/.github/angular-robot.yml +++ b/.github/angular-robot.yml @@ -5,20 +5,22 @@ merge: # the status will be added to your pull requests status: # set to true to disable - disabled: false + disabled: true # the name of the status - context: "ci/angular: merge status" + context: 'ci/angular: merge status' # text to show when all checks pass - successText: "All checks passed!" + successText: 'All checks passed!' # text to show when some checks are failing - failureText: "The following checks are failing:" + failureText: 'The following checks are failing:' # comment that will be added to a PR when there is a conflict, leave empty or set to false to disable - mergeConflictComment: "Hi @{{PRAuthor}}! This PR has merge conflicts due to recent upstream merges. -\nPlease help to unblock it by resolving these conflicts. Thanks!" + mergeConflictComment: >- + Hi @{{PRAuthor}}! This PR has merge conflicts due to recent upstream merges. + + Please help to unblock it by resolving these conflicts. Thanks! # label to monitor - mergeLabel: "PR action: merge" + mergeLabel: 'action: merge' # list of checks that will determine if the merge label can be added checks: @@ -26,74 +28,65 @@ merge: noConflict: true # whether the PR should have all reviews completed. requireReviews: true - # list of labels that a PR needs to have, checked with a regexp (e.g. "PR target:" will work for the label "PR target: master") + # list of labels that a PR needs to have, checked with a regexp (e.g. "target:" will work for the label "target: major") requiredLabels: - - "PR target: *" - - "cla: yes" + - 'target: *' # list of labels that a PR shouldn't have, checked after the required labels with a regexp forbiddenLabels: - - "PR target: TBD" - - "PR action: cleanup" - - "PR action: review" - - "PR state: blocked" - - "cla: no" + - 'action: cleanup' + - 'action: review' + - 'PR state: blocked' # list of PR statuses that need to be successful - requiredStatuses: - - "continuous-integration/appveyor/pr" - - "ci/circleci: build" - - "ci/circleci: build-bazel" - - "ci/circleci: install" - - "ci/circleci: lint" - - "ci/circleci: validate" - - "ci/circleci: test" - - "ci/circleci: test-large" + # NOTE: Required PR statuses are now exclusively handled via Github configuration + requiredStatuses: [] # the comment that will be added when the merge label is added despite failing checks, leave empty or set to false to disable # {{MERGE_LABEL}} will be replaced by the value of the mergeLabel option # {{PLACEHOLDER}} will be replaced by the list of failing checks - mergeRemovedComment: "I see that you just added the `{{MERGE_LABEL}}` label, but the following checks are still failing: -\n{{PLACEHOLDER}} -\n -\n**If you want your PR to be merged, it has to pass all the CI checks.** -\n -\nIf you can't get the PR to a green state due to flakes or broken master, please try rebasing to master and/or restarting the CI job. If that fails and you believe that the issue is not due to your change, please contact the caretaker and ask for help." + mergeRemovedComment: >- + I see that you just added the `{{MERGE_LABEL}}` label, but the following + checks are still failing: + + {{PLACEHOLDER}} + + **If you want your PR to be merged, it has to pass all the CI checks.** + If you can't get the PR to a green state due to flakes or broken `main`, + please try rebasing to `main` and/or restarting the CI job. If that fails + and you believe that the issue is not due to your change, please contact the + caretaker and ask for help. # options for the triage plugin triage: # set to true to disable - disabled: false + disabled: true # number of the milestone to apply when the issue has not been triaged yet needsTriageMilestone: 11, # number of the milestone to apply when the issue is triaged defaultMilestone: 12, # arrays of labels that determine if an issue has been triaged by the caretaker l1TriageLabels: - - - - "comp: *" + - - 'area: *' # arrays of labels that determine if an issue has been fully triaged l2TriageLabels: - - - - "type: bug/fix" - - "severity*" - - "freq*" - - "comp: *" - - - - "type: feature" - - "comp: *" - - - - "type: refactor" - - "comp: *" - - - - "type: RFC / Discussion / question" - - "comp: *" - - - - "type: docs" - - "comp: *" + - - 'type: bug/fix' + - 'severity*' + - 'freq*' + - 'area: *' + - - 'type: feature' + - 'area: *' + - - 'type: refactor' + - 'area: *' + - - 'type: RFC / Discussion / question' + - 'area: *' + - - 'type: docs' + - 'area: *' # Size checking size: - circleCiStatusName: "ci/circleci: e2e-cli" + # Size checking for production build is performed via the E2E test `build/prod-build` + disabled: true + circleCiStatusName: 'ci/circleci: e2e-cli' maxSizeIncrease: 10000 - comment: true + comment: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000000..a01353b6bcf0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 + +updates: + - package-ecosystem: 'npm' + directory: '/' + schedule: + interval: 'daily' + commit-message: + prefix: 'build' + labels: + - 'area: build & ci' + - 'target: patch' + - 'action: merge' + # Disable version updates + # This does not affect security updates + open-pull-requests-limit: 0 diff --git a/.github/shared-actions/windows-bazel-test/action.yml b/.github/shared-actions/windows-bazel-test/action.yml new file mode 100644 index 000000000000..7a5cf7c0297b --- /dev/null +++ b/.github/shared-actions/windows-bazel-test/action.yml @@ -0,0 +1,42 @@ +name: Native Windows Bazel E2E test +description: Runs an Angular CLI e2e Bazel test on native Windows +author: Angular + +inputs: + test_target_name: + description: E2E test target name. + required: true + e2e_temp_dir: + description: 'The temporary directory path for E2E tests.' + required: false + # Use D:\\ by default as it's much faster + # See: https://devblogs.microsoft.com/commandline/share-environment-vars-between-wsl-and-windows + default: 'D:\\tmp_dir' + +runs: + using: 'composite' + steps: + - name: Set up temp directory + shell: bash + run: | + mkdir ${{ inputs.e2e_temp_dir }} + + - name: Convert symlinks for Windows host + shell: pwsh + run: | + $runfiles_dir = "./dist/bin/tests/${{inputs.test_target_name}}_/${{inputs.test_target_name}}.bat.runfiles" + + # Needed for resolution because Aspect/Bazel looks for repositories at `/external`. + # TODO(devversion): consult with Aspect on why this is needed. + Set-Location -Path "${runfiles_dir}\_main" + New-Item -ItemType SymbolicLink -Path "external" -Target ".." + + - name: Run CLI E2E tests + shell: bash + env: + BAZEL_BINDIR: '.' + E2E_TEMP: ${{ inputs.e2e_temp_dir }} + run: | + node ./scripts/windows-testing/parallel-executor.mjs \ + "./dist/bin/tests/${{ inputs.test_target_name }}_/${{ inputs.test_target_name }}.bat.runfiles" \ + ${{ inputs.test_target_name }} diff --git a/.github/workflows/assistant-to-the-branch-manager.yml b/.github/workflows/assistant-to-the-branch-manager.yml new file mode 100644 index 000000000000..1c47e2d87274 --- /dev/null +++ b/.github/workflows/assistant-to-the-branch-manager.yml @@ -0,0 +1,23 @@ +name: DevInfra + +on: + push: + # zizmor: ignore[dangerous-triggers] - {Trigger is safe as workflow does not checkout untrusted code} + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review, labeled] + +# Declare default permissions as read only. +permissions: + contents: read + +jobs: + assistant_to_the_branch_manager: + runs-on: ubuntu-latest + if: github.event.repository.fork == false + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: angular/dev-infra/github-actions/branch-manager@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + with: + angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000000..a25af2674538 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,256 @@ +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@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Generate JSON schema types + # Schema types are required to correctly lint the TypeScript code + run: pnpm run build-schema + - name: Run ESLint + run: pnpm lint --cache-strategy content + - name: Validate NgBot Configuration + run: pnpm ng-dev ngbot verify + - name: Validate Circular Dependencies + run: pnpm ts-circular-deps check + - name: Run Validation + run: pnpm admin validate + - name: Check tooling setup + run: pnpm check-tooling-setup + + build: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Build release targets + run: pnpm ng-dev release build + + test: + needs: build + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Run module and package tests + run: pnpm bazel test -- //... -//tests/... + + e2e: + needs: test + strategy: + fail-fast: false + matrix: + node: [22, 24, 26] + subset: [esbuild, webpack] + shard: [0, 1, 2, 3, 4, 5] + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Run CLI E2E tests + run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=6 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.${{ matrix.subset }}_node${{ matrix.node }} + + build-e2e-windows: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Build E2E tests for Windows on Linux + run: | + pnpm bazel build \ + --config=e2e \ + //tests:e2e.webpack_node24 \ + //tests:e2e.esbuild_node24 \ + --platforms=tools:windows_x64 \ + --enable_runfiles + - name: Store built Windows E2E tests + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: win-e2e-build-artifacts + path: | + dist/bin/tests/** + !**/node_modules/** + retention-days: 1 + if-no-files-found: 'error' + + e2e-windows: + needs: build-e2e-windows + strategy: + fail-fast: false + matrix: + node: [24] + subset: [esbuild, webpack] + shard: [0, 1, 2, 3, 4, 5] + runs-on: windows-2025 + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Download built Windows E2E tests + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: win-e2e-build-artifacts + path: dist/bin/tests/ + - name: Run CLI E2E tests + uses: ./.github/shared-actions/windows-bazel-test + with: + test_target_name: e2e.${{ matrix.subset }}_node${{ matrix.node }} + env: + E2E_SHARD_TOTAL: 6 + E2E_SHARD_INDEX: ${{ matrix.shard }} + + e2e-package-managers: + needs: test + strategy: + fail-fast: false + matrix: + node: [22] + subset: [yarn, pnpm, bun] + shard: [0, 1, 2] + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Run CLI E2E tests + run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=3 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.${{ matrix.subset }}_node${{ matrix.node }} + + e2e-snapshots: + needs: test + if: github.ref == 'refs/heads/main' + strategy: + fail-fast: false + matrix: + node: [22] + subset: [esbuild, webpack] + shard: [0, 1, 2, 3, 4, 5] + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Run CLI E2E tests + run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=6 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.snapshots.${{ matrix.subset }}_node${{ matrix.node }} + + browsers: + needs: build + runs-on: ubuntu-latest + name: Browser Compatibility Tests + env: + SAUCE_TUNNEL_IDENTIFIER: angular-cli-${{ github.workflow }}-${{ github.run_number }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Start Sauce Connect + uses: saucelabs/sauce-connect-action@cb88b508c6f9ff4d84490093733315dbd55de022 # v3 + timeout-minutes: 10 + with: + username: ${{ vars.SAUCE_USERNAME }} + accessKey: ${{ secrets.SAUCE_ACCESS_KEY }} + region: us-west-1 + tunnelName: ${{ env.SAUCE_TUNNEL_IDENTIFIER }} + proxyLocalhost: allow + # By default we disable SSL bumping for all requests. This is because SSL bumping is not needed + # for our test setup and in order to perform the SSL bumping, Saucelabs intercepts all HTTP + # requests in the tunnel VM and modifies them. This can cause flakiness as it makes all requests + # dependent on the SSL bumping middleware. + # See: https://wiki.saucelabs.com/display/DOCS/Troubleshooting+Sauce+Connect#TroubleshootingSauceConnect-DisablingSSLBumping + tlsPassthroughDomains: all + - name: Run E2E Browser tests + env: + SAUCE_USERNAME: ${{ vars.SAUCE_USERNAME }} + SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} + run: pnpm bazel test --config=saucelabs //tests:e2e.saucelabs + + publish-snapshots: + needs: build + runs-on: ubuntu-latest + env: + CIRCLE_BRANCH: ${{ github.ref_name }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - run: pnpm admin snapshots --verbose + env: + SNAPSHOT_BUILDS_GITHUB_TOKEN: ${{ secrets.SNAPSHOT_BUILDS_GITHUB_TOKEN }} diff --git a/.github/workflows/dev-infra.yml b/.github/workflows/dev-infra.yml new file mode 100644 index 000000000000..ad1b6160cd19 --- /dev/null +++ b/.github/workflows/dev-infra.yml @@ -0,0 +1,36 @@ +name: DevInfra + +on: + # zizmor: ignore[dangerous-triggers] - {Trigger is safe as workflow does not checkout untrusted code} + pull_request_target: + types: [opened, synchronize, reopened] + issues: + types: [opened, reopened] + +# Declare default permissions as read only. +permissions: + contents: read + +jobs: + labels: + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + steps: + - uses: angular/dev-infra/github-actions/labeling/pull-request@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + with: + angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} + post_approval_changes: + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + steps: + - uses: angular/dev-infra/github-actions/post-approval-changes@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + with: + angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} + issue_labels: + if: github.event_name == 'issues' + runs-on: ubuntu-latest + steps: + - uses: angular/dev-infra/github-actions/labeling/issue@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + with: + angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} + google-generative-ai-key: ${{ secrets.GOOGLE_GENERATIVE_AI_KEY }} diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml new file mode 100644 index 000000000000..fb4283ac905d --- /dev/null +++ b/.github/workflows/perf.yml @@ -0,0 +1,60 @@ +name: Performance Tracking + +on: + push: + branches: + - main + # Run workflows for all releasable branches + - '[0-9]+.[0-9]+.x' + +permissions: + contents: 'read' + +defaults: + run: + shell: bash + +jobs: + list: + timeout-minutes: 3 + runs-on: ubuntu-latest + outputs: + workflows: ${{ steps.workflows.outputs.workflows }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - id: workflows + run: echo "workflows=$(pnpm --silent ng-dev perf workflows --list)" >> "$GITHUB_OUTPUT" + + workflow: + timeout-minutes: 30 + runs-on: ubuntu-latest + needs: list + permissions: + contents: 'read' + id-token: 'write' + strategy: + matrix: + workflow: ${{ fromJSON(needs.list.outputs.workflows) }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Install node modules + run: pnpm install --frozen-lockfile + # We utilize the google-github-actions/auth action to allow us to get an active credential using workflow + # identity federation. This allows us to request short lived credentials on demand, rather than storing + # credentials in secrets long term. More information can be found at: + # https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-google-cloud-platform + - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + project_id: 'internal-200822' + workload_identity_provider: 'projects/823469418460/locations/global/workloadIdentityPools/measurables-tracking/providers/angular' + service_account: 'measures-uploader@internal-200822.iam.gserviceaccount.com' + - run: pnpm ng-dev perf workflows --name "$WORKFLOW" --commit-sha "$COMMIT_SHA" + env: + WORKFLOW: ${{ matrix.workflow }} + COMMIT_SHA: ${{ github.sha }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 000000000000..e39501440c89 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,217 @@ +name: Pull Request + +on: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +defaults: + run: + shell: bash + +jobs: + analyze: + runs-on: ubuntu-latest + outputs: + snapshots: ${{ steps.filter.outputs.snapshots }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: filter + with: + filters: | + snapshots: + - 'tests/e2e/ng-snapshot/package.json' + + lint: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup ESLint Caching + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .eslintcache + key: ${{ runner.os }}-${{ hashFiles('.eslintrc.json') }} + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Generate JSON schema types + # Schema types are required to correctly lint the TypeScript code + run: pnpm run build-schema + - name: Run ESLint + run: pnpm lint --cache-strategy content + - name: Validate NgBot Configuration + run: pnpm ng-dev ngbot verify + - name: Validate Circular Dependencies + run: pnpm ts-circular-deps check + - name: Run Validation + run: pnpm admin validate + - name: Check tooling setup + run: pnpm check-tooling-setup + - name: Check commit message + # Commit message validation is only done on pull requests as its too late to validate once + # it has been merged. + run: pnpm ng-dev commit-message validate-range ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }} + - name: Check code format + # Code formatting checks are only done on pull requests as its too late to validate once + # it has been merged. + run: pnpm ng-dev format changed --check ${{ github.event.pull_request.base.sha }} + - name: Check Package Licenses + uses: angular/dev-infra/github-actions/linting/licenses@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + + build: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Build release targets + run: pnpm ng-dev release build + - name: Store PR release packages + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: packages + path: dist/releases/*.tgz + retention-days: 14 + + test: + needs: build + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Run module and package tests + run: pnpm bazel test -- //... -//tests/... + + e2e: + needs: build + strategy: + fail-fast: false + matrix: + node: [22] + subset: [esbuild, webpack] + shard: [0, 1, 2, 3, 4, 5] + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Run CLI E2E tests + run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=6 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.${{ matrix.subset }}_node${{ matrix.node }} + + build-e2e-windows-subset: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Build E2E tests for Windows on Linux + run: | + pnpm bazel build \ + --config=e2e \ + //tests:e2e.esbuild_node24 \ + --platforms=tools:windows_x64 \ + --enable_runfiles + - name: Store built Windows E2E tests + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: win-e2e-build-artifacts + path: | + dist/bin/tests/** + !**/node_modules/** + retention-days: 1 + if-no-files-found: 'error' + + e2e-windows-subset: + needs: build-e2e-windows-subset + runs-on: windows-2025 + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Download built Windows E2E tests + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: win-e2e-build-artifacts + path: dist/bin/tests/ + - name: Run CLI E2E tests + uses: ./.github/shared-actions/windows-bazel-test + with: + test_target_name: e2e.esbuild_node24 + env: + E2E_SHARD_TOTAL: 1 + TESTBRIDGE_TEST_ONLY: tests/basic/{build,rebuild,serve}.ts + + e2e-package-managers: + needs: build + strategy: + fail-fast: false + matrix: + node: [22] + subset: [yarn, pnpm, bun] + shard: [0, 1, 2] + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Run CLI E2E tests + run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=3 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.${{ matrix.subset }}_node${{ matrix.node }} + + e2e-snapshots: + needs: [analyze, build] + if: needs.analyze.outputs.snapshots == 'true' + strategy: + fail-fast: false + matrix: + node: [22] + subset: [esbuild, webpack] + shard: [0, 1, 2, 3, 4, 5] + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@5708c4df037871bab3ce15d27e020dbdbe9fe0fc # main + - name: Run CLI E2E tests + run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=6 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.snapshots.${{ matrix.subset }}_node${{ matrix.node }} diff --git a/.gitignore b/.gitignore index 88895c7481f5..83582f8ece43 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,39 @@ test-project-host-* dist/ dist-schema/ +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + # IDEs -.idea/ jsconfig.json -.vscode/ + +# Intellij IDEA/WebStorm +# https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 +.idea/inspectionProfiles/ +.idea/**/compiler.xml +.idea/**/encodings.xml +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Also ignore code styles because .editorconfig is used instead. +.idea/codeStyles/ + +# VSCode +# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +**/*.code-workspace # Typings file. typings/ @@ -19,6 +48,12 @@ tmp/ npm-debug.log* yarn-error.log* .ng_pkg_build/ +.ng-dev.log +.ng-dev.err*.log +.ng-dev.user* +.husky/_ +.bazelrc.user +.eslintcache # Mac OSX Finder files. **/.DS_Store diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 000000000000..de361903a541 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +pnpm --silent ng-dev commit-message pre-commit-validate --file $1; diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000000..7f9d46b47835 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm --silent ng-dev format staged; diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg new file mode 100755 index 000000000000..f4598b6d1b6e --- /dev/null +++ b/.husky/prepare-commit-msg @@ -0,0 +1 @@ +pnpm --silent ng-dev commit-message restore-commit-message-draft $1 $2; diff --git a/.mailmap b/.mailmap index bf8640d5dec9..f651b93dd40b 100644 --- a/.mailmap +++ b/.mailmap @@ -16,6 +16,10 @@ Charles Lyding Charles Lyding <19598772+clydin@users.noreply.github.com> Filipe Silva Mike Brocchi +Alan Agius <17563226+alan-agius4@users.noreply.github.com> + Alan Agius <17563226+alan-agius4@users.noreply.github.com> + Alan Agius <17563226+alan-agius4@users.noreply.github.com> + Alan Agius <17563226+alan-agius4@users.noreply.github.com> ################################################################################ diff --git a/.monorepo.json b/.monorepo.json index d6c4d6f3b765..ad86e09acdeb 100644 --- a/.monorepo.json +++ b/.monorepo.json @@ -1,57 +1,17 @@ { - "badges": [ - [ - { - "image": "https://img.shields.io/circleci/project/github/angular/angular-cli/master.svg?label=circleci", - "label": "CircleCI branch", - "url": "https://circleci.com/gh/angular/angular-cli" - }, - { - "title": "![Dependency Status](https://david-dm.org/angular/angular-cli.svg)", - "url": "https://david-dm.org/angular/angular-cli" - }, - { - "title": "![devDependency Status](https://david-dm.org/angular/angular-cli/dev-status.svg)", - "url": "https://david-dm.org/angular/angular-cli?type=dev" - } - ], - [ - { - "label": "License", - "image": "https://img.shields.io/npm/l/@angular/cli.svg", - "url": "https://github.com/angular/angular-cli/blob/master/LICENSE" - } - ], - [ - { - "label": "GitHub forks", - "image": "https://img.shields.io/github/forks/angular/angular-cli.svg?style=social&label=Fork", - "url": "https://github.com/angular/angular-cli/fork" - }, - { - "label": "GitHub stars", - "image": "https://img.shields.io/github/stars/angular/angular-cli.svg?style=social&label=Star", - "url": "https://github.com/angular/angular-cli" - } - ] - ], - "links": { - "Gitter": "https://gitter.im/angular/angular-cli", - "Contributing": "https://github.com/angular/angular-cli/blob/master/CONTRIBUTING.md", - "Angular CLI": "http://github.com/angular/angular-cli" - }, "packages": { - "@_/benchmark": { - "version": "0.9.0-rc.3", - "hash": "f828d4a3fa042a7feac4a48c86893375" - }, - "@_/builders": { - "version": "0.9.0-rc.3", - "hash": "6983d21ece5e42aa536b50adfdceb56c" - }, - "devkit": { - "version": "0.9.0-rc.3", - "hash": "4f379b7a9eea07dbb4ce5d00943da2a7" + "@_/builders": {}, + "devkit": {}, + "@angular/build": { + "name": "Angular Build System", + "section": "Tooling", + "links": [ + { + "label": "README", + "url": "/packages/angular/build/README.md" + } + ], + "snapshotRepo": "angular/angular-build-builds" }, "@angular/cli": { "name": "Angular CLI", @@ -59,78 +19,54 @@ "links": [ { "label": "README", - "url": "https://github.com/angular/angular-cli/blob/master/packages/angular/cli/README.md" + "url": "/packages/angular/cli/README.md" } ], - "version": "7.0.0-rc.3", - "snapshotRepo": "angular/cli-builds", - "hash": "1a81b6f6ae267f7e2ce5e6e541e6c5f1" + "snapshotRepo": "angular/cli-builds" }, - "@angular/pwa": { - "name": "Angular PWA Schematics", - "section": "Schematics", - "version": "0.9.0-rc.3", - "hash": "d5a17c053d50acc4371cf8836bd4e305", - "snapshotRepo": "angular/angular-pwa-builds" - }, - "@angular-devkit/architect": { - "name": "Architect", + "@angular/create": { + "name": "Angular Create", + "section": "Misc", "links": [ { "label": "README", - "url": "https://github.com/angular/angular-cli/blob/master/packages/angular_devkit/architect/README.md" + "url": "/packages/angular/create/README.md" } - ], - "version": "0.9.0-rc.3", - "hash": "69de58e95e413c7f31288805502ac7cc", - "snapshotRepo": "angular/angular-devkit-architect-builds" + ] }, - "@angular-devkit/architect-cli": { - "name": "Architect CLI", - "version": "0.9.0-rc.3", - "hash": "56b94bb6df457dfb8247b12ad79c80ff", - "snapshotRepo": "angular/angular-devkit-architect-cli-builds" - }, - "@angular-devkit/benchmark": { - "name": "Benchmark", - "section": "Tooling", - "version": "1.0.0-rc.3", - "hash": "b22aede83c4a65b920886c2b7c07e617" + "@angular/pwa": { + "name": "Angular PWA Schematics", + "section": "Schematics", + "snapshotRepo": "angular/angular-pwa-builds" }, - "@angular-devkit/build-optimizer": { - "name": "Build Optimizer", + "@angular/ssr": { + "name": "Angular SSR", "links": [ { "label": "README", - "url": "https://github.com/angular/angular-cli/blob/master/packages/angular_devkit/build_optimizer/README.md" + "url": "/packages/angular/ssr/README.md" } ], - "version": "0.9.0-rc.3", - "hash": "843110dac52428005e98af191cbdee7c", - "snapshotRepo": "angular/angular-devkit-build-optimizer-builds" + "snapshotRepo": "angular/angular-ssr-builds" }, - "@angular-devkit/build-ng-packagr": { - "name": "Build NgPackagr", + "@angular-devkit/architect": { + "name": "Architect", "links": [ { "label": "README", - "url": "https://github.com/angular/angular-cli/blob/master/packages/angular_devkit/build_ng_packagr/README.md" + "url": "/packages/angular_devkit/architect/README.md" } ], - "version": "0.9.0-rc.3", - "hash": "70953928c8b694c84bd17679f32e6370", - "snapshotRepo": "angular/angular-devkit-build-ng-packagr-builds" + "snapshotRepo": "angular/angular-devkit-architect-builds" }, "@angular-devkit/build-angular": { "name": "Build Angular", "links": [ { "label": "README", - "url": "https://github.com/angular/angular-cli/blob/master/packages/angular_devkit/build_angular/README.md" + "url": "/packages/angular_devkit/build_angular/README.md" } ], - "version": "0.9.0-rc.3", - "hash": "977fc0e11ce8e8cba6f1c510b2c9332f", "snapshotRepo": "angular/angular-devkit-build-angular-builds" }, "@angular-devkit/build-webpack": { @@ -138,23 +74,19 @@ "links": [ { "label": "README", - "url": "https://github.com/angular/angular-cli/blob/master/packages/angular_devkit/build_webpack/README.md" + "url": "/packages/angular_devkit/build_webpack/README.md" } ], - "version": "0.9.0-rc.3", - "snapshotRepo": "angular/angular-devkit-build-webpack-builds", - "hash": "993a11de93afac81b572e4a4cf0da29f" + "snapshotRepo": "angular/angular-devkit-build-webpack-builds" }, "@angular-devkit/core": { "name": "Core", "links": [ { "label": "README", - "url": "https://github.com/angular/angular-cli/blob/master/packages/angular_devkit/core/README.md" + "url": "/packages/angular_devkit/core/README.md" } ], - "version": "7.0.0-rc.3", - "hash": "2c72e31bd22c0f247c510f2dde19b2df", "snapshotRepo": "angular/angular-devkit-core-builds" }, "@angular-devkit/schematics": { @@ -162,47 +94,25 @@ "links": [ { "label": "README", - "url": "https://github.com/angular/angular-cli/blob/master/packages/angular_devkit/schematics/README.md" + "url": "/packages/angular_devkit/schematics/README.md" } ], - "version": "7.0.0-rc.3", - "hash": "ccfc55132a0c921747352f35a8ceed2f", "snapshotRepo": "angular/angular-devkit-schematics-builds" }, "@angular-devkit/schematics-cli": { "name": "Schematics CLI", "section": "Tooling", - "version": "0.9.0-rc.3", - "hash": "5e241ab9106459239f259a83416ef7bf", "snapshotRepo": "angular/angular-devkit-schematics-cli-builds" }, "@ngtools/webpack": { "name": "Webpack Angular Plugin", - "version": "7.0.0-rc.3", "section": "Misc", - "hash": "f69c409a11ed2b38ee411bb879646a5a", "snapshotRepo": "angular/ngtools-webpack-builds" }, "@schematics/angular": { "name": "Angular Schematics", "section": "Schematics", - "version": "7.0.0-rc.3", - "hash": "a82c0c20dbc83e0a2accfa3512da89c1", "snapshotRepo": "angular/schematics-angular-builds" - }, - "@schematics/schematics": { - "name": "Schematics Schematics", - "version": "0.9.0-rc.3", - "section": "Schematics", - "hash": "f3be77421fb1e428cf6ed65569260934", - "snapshotRepo": "angular/schematics-schematics-builds" - }, - "@schematics/update": { - "name": "Package Update Schematics", - "version": "0.9.0-rc.3", - "section": "Schematics", - "hash": "e8706489d428c28fc2e66fd8fb78d26d", - "snapshotRepo": "angular/schematics-update-builds" } } } diff --git a/.ng-dev/caretaker.mjs b/.ng-dev/caretaker.mjs new file mode 100644 index 000000000000..d21e783f4af5 --- /dev/null +++ b/.ng-dev/caretaker.mjs @@ -0,0 +1,17 @@ +/** + * The configuration for `ng-dev caretaker` commands. + * + * @type { import("@angular/ng-dev").CaretakerConfig } + */ +export const caretaker = { + githubQueries: [ + { + name: 'Merge Queue', + query: `is:pr is:open status:success label:"action: merge"`, + }, + { + name: 'Merge Assistance Queue', + query: `is:pr is:open label:"action: merge-assistance"`, + }, + ], +}; diff --git a/.ng-dev/commit-message.mjs b/.ng-dev/commit-message.mjs new file mode 100644 index 000000000000..d30060674355 --- /dev/null +++ b/.ng-dev/commit-message.mjs @@ -0,0 +1,14 @@ +import { packages } from '../scripts/packages.mts'; + +/** + * The configuration for `ng-dev commit-message` commands. + * + * @type { import("@angular/ng-dev").CommitMessageConfig } + */ +export const commitMessage = { + maxLineLength: Infinity, + minBodyLength: 0, + minBodyLengthTypeExcludes: ['docs'], + // Note: When changing this logic, also change the `contributing.ejs` file. + scopes: packages.map(({ name }) => name), +}; diff --git a/.ng-dev/config.mjs b/.ng-dev/config.mjs new file mode 100644 index 000000000000..6add9773c06c --- /dev/null +++ b/.ng-dev/config.mjs @@ -0,0 +1,6 @@ +export { commitMessage } from './commit-message.mjs'; +export { format } from './format.mjs'; +export { github } from './github.mjs'; +export { pullRequest } from './pull-request.mjs'; +export { release } from './release.mjs'; +export { caretaker } from './caretaker.mjs'; diff --git a/.ng-dev/dx-perf-workflows.yml b/.ng-dev/dx-perf-workflows.yml new file mode 100644 index 000000000000..70960ff760db --- /dev/null +++ b/.ng-dev/dx-perf-workflows.yml @@ -0,0 +1,49 @@ +workflows: + build-cli: + name: '@angular/cli build' + prepare: + - bazel clean + workflow: + - bazel build //packages/angular/cli:npm_package + + angular-build-integration: + name: '@angular/build integration' + disabled: true + prepare: + - bazel clean + workflow: + - bazel test //packages/angular/build:integration_tests + + modules-builder-tests: + name: '@ngtools/webpack test' + prepare: + - bazel clean + workflow: + - bazel test //packages/ngtools/webpack:test + + devkit-core-tests: + name: '@angular/devkit/core tests' + prepare: + - bazel clean + workflow: + - bazel test //packages/angular_devkit/core:test + + devkit-core-tests-rerun: + name: '@angular/devkit/core return test' + prepare: + - bazel clean + workflow: + - bazel test //packages/angular_devkit/core:test + # Add a single line to the beginning of a file to trigger a rebuild/retest + - sed -i '1i // comment' packages/angular_devkit/core/src/workspace/core_spec.ts + - bazel test //packages/angular_devkit/core:test + cleanup: + # Remove the single line added + - sed -i '1d' packages/angular_devkit/core/src/workspace/core_spec.ts + + build-unit-tests: + name: '@angular/build tests' + prepare: + - bazel clean + workflow: + - bazel test //packages/angular/build:test diff --git a/.ng-dev/format.mjs b/.ng-dev/format.mjs new file mode 100644 index 000000000000..c750eeea395c --- /dev/null +++ b/.ng-dev/format.mjs @@ -0,0 +1,9 @@ +/** + * Configuration for the `ng-dev format` command. + * + * @type { import("@angular/ng-dev").FormatConfig } + */ +export const format = { + 'prettier': true, + 'buildifier': true, +}; diff --git a/.ng-dev/github.mjs b/.ng-dev/github.mjs new file mode 100644 index 000000000000..467741f70f83 --- /dev/null +++ b/.ng-dev/github.mjs @@ -0,0 +1,13 @@ +/** + * Github configuration for the ng-dev command. This repository is + * used as remote for the merge script. + * + * @type { import("@angular/ng-dev").GithubConfig } + */ +export const github = { + owner: 'angular', + name: 'angular-cli', + mainBranchName: 'main', + useNgDevAuthService: true, + mergeMode: 'team-only', +}; diff --git a/.ng-dev/pull-request.mjs b/.ng-dev/pull-request.mjs new file mode 100644 index 000000000000..1f2d84d7ccba --- /dev/null +++ b/.ng-dev/pull-request.mjs @@ -0,0 +1,12 @@ +/** + * Configuration for the merge tool in `ng-dev`. This sets up the labels which + * are respected by the merge script (e.g. the target labels). + * + * @type { import("@angular/ng-dev").PullRequestConfig } + */ +export const pullRequest = { + githubApiMerge: { + default: 'auto', + labels: [{ pattern: 'merge: squash commits', method: 'squash' }], + }, +}; diff --git a/.ng-dev/release.mjs b/.ng-dev/release.mjs new file mode 100644 index 000000000000..19e111f22f80 --- /dev/null +++ b/.ng-dev/release.mjs @@ -0,0 +1,48 @@ +import semver from 'semver'; +import { releasePackages } from '../scripts/packages.mts'; + +/** + * Configuration for the `ng-dev release` command. + * + * @type { import("@angular/ng-dev").ReleaseConfig } + */ +export const release = { + representativeNpmPackage: '@angular/cli', + npmPackages: releasePackages.map(({ name, experimental }) => { + if ( + name === '@angular-devkit/build-angular' || + name === '@angular-devkit/build-webpack' || + name === '@ngtools/webpack' + ) { + return { + name, + experimental, + deprecated: { + version: '>=22.0.0-rc.0', + message: + 'Angular\'s Webpack support is deprecated. Use the esbuild and Vite-based "@angular/build" package instead.', + }, + }; + } + + return { name, experimental }; + }), + buildPackages: async () => { + // The `performNpmReleaseBuild` function is loaded at runtime to avoid loading additional + // files and dependencies unless a build is required. + const { performNpmReleaseBuild } = await import('../scripts/build-packages-dist.mts'); + return performNpmReleaseBuild(); + }, + prereleaseCheck: async (newVersionStr) => { + const newVersion = new semver.SemVer(newVersionStr); + const { assertValidDependencyRanges } = + await import('../scripts/release-checks/dependency-ranges/index.mts'); + + await assertValidDependencyRanges(newVersion, releasePackages); + }, + releaseNotes: { + groupOrder: ['@angular/cli', '@schematics/angular', '@angular-devkit/schematics-cli'], + }, + publishRegistry: 'https://wombat-dressing-room.appspot.com', + releasePrLabels: ['action: merge'], +}; diff --git a/.ng-dev/tsconfig.json b/.ng-dev/tsconfig.json new file mode 100644 index 000000000000..a250849eb673 --- /dev/null +++ b/.ng-dev/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "resolveJsonModule": true, + "allowJs": true, + "rewriteRelativeImportExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "module": "Node16", + "moduleResolution": "Node16", + "checkJs": true, + "noEmit": true, + "types": [] + }, + "include": ["**/*.mjs"], + "exclude": [] +} diff --git a/.nvmrc b/.nvmrc index f599e28b8ab0..b74495a07da3 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -10 +24.22.2 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000000..1adbf7759080 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,17 @@ +/bazel-out/ +/docs/design/analytics.md +/dist-schema/ +/goldens/public-api +/modules/testing/builder/projects/ +/packages/angular_devkit/build_angular/test/ +/packages/angular_devkit/core/src/workspace/json/test/ +/packages/angular_devkit/schematics_cli/blank/project-files/ +/packages/angular_devkit/schematics_cli/blank/schematic-files/ +/packages/angular_devkit/schematics_cli/schematic/files/ +/packages/schematics/angular/third_party/ +/README.md +/CONTRIBUTING.md +dist/ +/tests/e2e/assets/ +/tools/test/*.json +pnpm-lock.yaml diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000000..22e9e4076522 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 100, + "quoteProps": "preserve", + "singleQuote": true +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000000..6f3e0e3d3375 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,49 @@ +// A launch configuration that compiles the extension and then opens it inside a new window +{ + "version": "0.2.0", + "configurations": [ + { + "name": "DEBUG: Attach to bazel test", + "type": "node", + "request": "attach", + "port": 9229, + "restart": true, + "timeout": 600000, + "sourceMaps": true, + "skipFiles": ["/**"], + "sourceMapPathOverrides": { + "?:*@0.0.0/node_modules/@angular-devkit/build-angular/*": "${workspaceFolder}/packages/angular_devkit/build_angular/*", + "?:*@0.0.0/node_modules/@angular-devkit/build-webpack/*": "${workspaceFolder}/packages/angular_devkit/build_webpack/*", + "?:*@0.0.0/node_modules/@angular-devkit/*": "${workspaceFolder}/packages/angular_devkit/*", + "?:*@0.0.0/node_modules/@angular/*": "${workspaceFolder}/packages/angular/*", + "?:*/bin/*": "${workspaceFolder}/*" + }, + "resolveSourceMapLocations": ["*/**", "!**/rxjs**"] + }, + { + "name": "DEBUG: Run bazel test (Custom Target)", + "type": "node", + "request": "launch", + "restart": true, + "timeout": 600000, + "runtimeExecutable": "pnpm", + "runtimeArgs": ["bazel", "test", "${input:bazelTarget}", "--config=debug"], + "console": "integratedTerminal", + "cwd": "${workspaceFolder}" + } + ], + "compounds": [ + { + "name": "DEBUG: Run bazel test (Custom Target) + Attach", + "configurations": ["DEBUG: Attach to bazel test", "DEBUG: Run bazel test (Custom Target)"] + } + ], + "inputs": [ + { + "id": "bazelTarget", + "type": "promptString", + "description": "Enter the Bazel test target (e.g., //path/to/my:unit_test)", + "default": "//packages/..." + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000000..b98a874af297 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,26 @@ +{ + "[javascript]": { + "editor.formatOnSave": true + }, + "[typescript]": { + "editor.formatOnSave": true + }, + // Exclude third party modules and build artifacts from the editor watchers/searches. + "files.watcherExclude": { + "**/.git/objects/**": true, + "**/.git/subtree-cache/**": true, + "**/node_modules/**": true, + "**/bazel-out/**": true, + "**/dist/**": true, + "**/dist-schema/**": true + }, + "search.exclude": { + "**/node_modules": true, + "**/bazel-out": true, + "**/dist": true, + "**/dist-schema": true, + ".history": true + }, + "git.ignoreLimitWarning": true, + "gitlens.advanced.blame.customArguments": ["--ignore-revs-file .git-blame-ignore-revs"] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000000..7ed3ec6a1316 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,77 @@ +--- +trigger: always_on +--- + +This is the source code for the Angular CLI and related build tooling. This guide outlines standard practices for AI agents working in this repository. + +## Environment + +- Use `pnpm` for package management. +- Use `pnpm bazel test //target` to run tests. + +## Key Documentation + +- [Developer Guide](docs/DEVELOPER.md): definitive guide for building, debugging, and running test targets. +- [Contributing Guide](CONTRIBUTING.md): general contribution workflows and guidelines. +- [Commit Guidelines](CONTRIBUTING.md#commit): format for commit messages and PR titles. + +## Building + +- Make a local build of all packages: + ```shell + pnpm build --local + ``` + +## Coding Practices + +- **Imports:** + - Always use the `node:` protocol for Node.js built-in imports (e.g., `node:fs`, `node:path`, `node:assert`). + - Prefer named imports (e.g., `import { mkdtemp } from 'node:fs'`) or default imports (`import fs from 'node:fs'`) instead of namespace imports (`import * as fs`). + - Use type-only imports (`import type { ... }`) when importing types to avoid runtime side-effects. +- **Classes:** + - Prefer ECMAScript private fields (`#field`) over TypeScript `private` keywords for encapsulated state. + +## Testing + +- **Temporary Directories (`TEST_TMPDIR`):** + - Tests in this repository only run in Bazel. **ALWAYS** use `process.env['TEST_TMPDIR']` and assert that it is set: + ```ts + import assert from 'node:assert'; + import { mkdtemp } from 'node:fs/promises'; + import { join } from 'node:path'; + + describe('...', () => { + let tempRoot: string; + + beforeAll(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-test-')); + }); + }); + ``` + - **NEVER** use or fallback to `os.tmpdir()`. Bazel executes tests in hermetic sandboxes and sets `TEST_TMPDIR` to an isolated, sandboxed directory. Using `os.tmpdir()` can cause sandboxing failures, permission errors, or file leakage outside the Bazel sandbox. +- **Unit & Integration Tests:** + - Run all unit and integration tests: `pnpm bazel test //packages/...` + - Run a specific unit test target (e.g., `pnpm bazel test //packages/angular/build:test`). Note that package `:test` targets may exclude builder integration tests (such as specs under `**/builders/**/tests/**`). + - Run a specific builder integration test target (e.g., `pnpm bazel test //packages/angular/build:library_integration_tests` or `pnpm bazel test //packages/angular/build:application_integration_tests`). + - Query all test targets: `pnpm bazel query "tests(//packages/...)"` + - Find the exact test target for a specific spec file (using its workspace-relative path, which Bazel resolves as a source file target): `pnpm bazel query "kind(jasmine_test, rdeps(//packages/..., ))"` + - Focus specific tests when debugging: use `fdescribe()` and `fit()`. NEVER commit focused tests to the repository. + - Run tests without sharding when isolating or debugging: use `--config=no-sharding` with a specific test target (e.g., `pnpm bazel test //packages/angular/build:library_integration_tests --config=no-sharding`). + This disables test sharding (`--test_sharding_strategy=disabled`) and flaky test retries (`--flaky_test_attempts=1`). + This is especially useful when isolating test runs or debugging with focused tests (`fit`/`fdescribe`) to avoid empty shard failures and unnecessary re-runs. + Do not use this flag when running broad test suites (such as `//packages/...`), as executing tests without sharding takes significantly longer. +- **End-to-End Tests:** + - Run subset of E2E tests: `pnpm bazel test //tests:e2e_node22 --config=e2e --test_filter=""` + +## Pull Requests + +- Use the `gh` CLI (GitHub CLI) for creating and managing pull requests. +- **Fixup Commits:** + - When addressing review feedback, **ALWAYS** use fixup commits (`git commit --fixup `) instead of amending existing commits. This preserves commit history during review and allows reviewers to easily see incremental changes. + - Only use fixup commits for changes that directly belong to the target commit. Unrelated changes must be made in a separate commit with their own commit message, not as a fixup commit. + - Fixup commits are automatically squashed when merging with `pnpm ng-dev pr merge` or optionally when rebasing with `pnpm ng-dev pr rebase `. +- Use `pnpm ng-dev pr` commands: + - `pnpm ng-dev pr rebase `: Rebase a PR branch on its target branch and optionally squash fixup commits. + - `pnpm ng-dev pr merge `: Merge an approved PR into its targeted branches. diff --git a/BUILD b/BUILD deleted file mode 100644 index 10cfed3bc771..000000000000 --- a/BUILD +++ /dev/null @@ -1,80 +0,0 @@ -# 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 -package(default_visibility = ["//visibility:public"]) - -licenses(["notice"]) # MIT License - -exports_files([ - "LICENSE", - "tsconfig.json", # @external - "tslint.base.json", # @external -]) - -# NOTE: this will move to node_modules/BUILD in a later release -# @external_begin -NODE_MODULES_EXCLUDE = [ - # e.g. node_modules/adm-zip/test/assets/attributes_test/New folder/hidden.txt - "node_modules/**/test/**", - # e.g. node_modules/xpath/docs/function resolvers.md - "node_modules/**/docs/**", - # e.g. node_modules/puppeteer/.local-chromium/mac-536395/chrome-mac/Chromium.app/Contents/Versions/66.0.3347.0/Chromium Framework.framework/Chromium Framework - "node_modules/**/.*/**", - # Ignore paths with spaces. - "node_modules/**/* *", -] - -filegroup( - name = "node_modules", - srcs = glob( - # Only include files we might use, to reduce the file count and surface size of - # filename problems. - [ - "node_modules/**/*.js", - "node_modules/**/*.json", - "node_modules/**/*.d.ts", - ], - exclude = NODE_MODULES_EXCLUDE, - ) + glob(["node_modules/.bin/*"]), -) - -# node_modules filegroup for tools/quicktype_runner, which contains quicktype-core and tslint. -QUICKTYPE_TRANSITIVE_DEPENDENCIES = [ - "collection-utils", - "core-util-is", - "inherits", - "isarray", - "js-base64", - "pako", - "pluralize", - "process-nextick-args", - "quicktype-core", - "readable-stream", - "safe-buffer", - "stream-json", - "string-to-stream", - "tiny-inflate", - "unicode-properties", - "unicode-trie", - "urijs", - "util-deprecate", - "wordwrap", -] - -filegroup( - name = "quicktype_node_modules", - srcs = glob( - [ - "/".join([ - "node_modules", "**", pkg, "**/*.js", - ]) for pkg in QUICKTYPE_TRANSITIVE_DEPENDENCIES - ] + [ - "/".join([ - "node_modules", "**", pkg, "**/*.json", - ]) for pkg in QUICKTYPE_TRANSITIVE_DEPENDENCIES - ], - exclude = NODE_MODULES_EXCLUDE, - ) -) -# @external_end diff --git a/BUILD.bazel b/BUILD.bazel new file mode 100644 index 000000000000..959b28996ec7 --- /dev/null +++ b/BUILD.bazel @@ -0,0 +1,111 @@ +load("@aspect_rules_ts//ts:defs.bzl", rules_js_tsconfig = "ts_config") +load("@bazel_skylib//rules:common_settings.bzl", "bool_flag") +load("@devinfra//bazel/validation:defs.bzl", "validate_ts_version_matching") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("//tools:defaults.bzl", "copy_to_bin") + +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +exports_files([ + "LICENSE", + "tsconfig.json", + "tsconfig-test.json", + "tsconfig-build-ng.json", + "tsconfig-build.json", + "package.json", + "node_modules/@angular/core/resources/best-practices.md", +]) + +npm_link_all_packages() + +rules_js_tsconfig( + name = "tsconfig", + src = "tsconfig.json", + visibility = [ + "//:__pkg__", + ], +) + +rules_js_tsconfig( + name = "build-tsconfig", + src = "tsconfig-build.json", + deps = [ + ":tsconfig", + "//:node_modules/@types/node", + ], +) + +rules_js_tsconfig( + name = "test-tsconfig", + src = "tsconfig-test.json", + deps = [ + ":tsconfig", + "//:node_modules/@types/jasmine", + "//:node_modules/@types/node", + ], +) + +rules_js_tsconfig( + name = "build-tsconfig-esm", + src = "tsconfig-build-esm.json", + deps = [ + ":tsconfig", + ], +) + +rules_js_tsconfig( + name = "test-tsconfig-esm", + src = "tsconfig-test-esm.json", + deps = [ + ":build-tsconfig-esm", + "//:node_modules/@types/jasmine", + "//:node_modules/@types/node", + ], +) + +# Files required by e2e tests +copy_to_bin( + name = "config-files", + srcs = [ + "package.json", + ], +) + +# Detect if the build is running under --stamp +config_setting( + name = "stamp", + values = {"stamp": "true"}, +) + +# If set will replace dependency versions with tarballs for packages in this repo +bool_flag( + name = "enable_package_json_tar_deps", + build_setting_default = False, +) + +config_setting( + name = "package_json_use_tar_deps", + flag_values = { + ":enable_package_json_tar_deps": "true", + }, +) + +# If set will replace dependency versions with snapshot repos for packages in this repo +bool_flag( + name = "enable_snapshot_repo_deps", + build_setting_default = False, +) + +config_setting( + name = "package_json_use_snapshot_repo_deps", + flag_values = { + ":enable_snapshot_repo_deps": "true", + }, +) + +validate_ts_version_matching( + module_bazel = "MODULE.bazel", + package_json = "package.json", +) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000000..e72e912e9ad2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19408 @@ + + +# 22.3.0-next.4 (2026-10-28) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [7d6550ef25](https://github.com/angular/angular-cli/commit/7d6550ef2502d628cc65f1e4006065f40c242126) | feat | support Oxfmt for automatic file formatting | +| [540a1c495d](https://github.com/angular/angular-cli/commit/540a1c495de349b77ace17e0ad460991f6c680c2) | fix | explain how to pick a project when a schematic cannot determine one | +| [322777d80b](https://github.com/angular/angular-cli/commit/322777d80b7590059b6ba89d2abee8ab5381db3e) | fix | handle bun registry error output and 404 detection | +| [3adad37a90](https://github.com/angular/angular-cli/commit/3adad37a9083923ccf51c5ef70b3199c8248f47e) | fix | support resolving subproject dependencies in bun workspaces | + +### @schematics/angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [6588204b8f](https://github.com/angular/angular-cli/commit/6588204b8f20839948e8d3a961f80488da8db7dc) | fix | avoid adding web worker snippet to server configuration files | +| [a68d0df2ca](https://github.com/angular/angular-cli/commit/a68d0df2ca5d33aa953324e3ea8d3680da8af567) | fix | ignore non-workspace stylesheet entries when adding Tailwind | +| [43c401c6b4](https://github.com/angular/angular-cli/commit/43c401c6b4b496a7a1ac88cae4460fb1d198429b) | fix | include the SSR entry file in the trust-proxy-headers migration | +| [4a59335cc5](https://github.com/angular/angular-cli/commit/4a59335cc55b3c6a9bd096d4ffabcd8b39a6281c) | fix | include the type separator in the class spec import | +| [4ea7c673e0](https://github.com/angular/angular-cli/commit/4ea7c673e06c712d96dd330f99dd291232f346f1) | fix | keep the default getter access type of spyOnProperty in refactor-jasmine-vitest | +| [54f261817c](https://github.com/angular/angular-cli/commit/54f261817c37b0c11cb8c7cde6d97c97e4c061b2) | fix | migrate CallInfo returnValue and object in refactor-jasmine-vitest | +| [d3acc84bed](https://github.com/angular/angular-cli/commit/d3acc84bedef1dd89e532cb625c1e7ecceee822f) | fix | migrate spy.calls.first().args in refactor-jasmine-vitest | +| [02a067813b](https://github.com/angular/angular-cli/commit/02a067813bbaaf75c7a949d1da1b64a32ba101da) | fix | set explicit type for the `module --route` component | +| [0be52a8834](https://github.com/angular/angular-cli/commit/0be52a8834dedf0264f43e79e9a98d5e1dafe6dd) | fix | set explicit type for the app-shell component | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------- | +| [d2aafa2d25](https://github.com/angular/angular-cli/commit/d2aafa2d25a69b5e4d2e08414c296a2cd851caa8) | fix | resolve dev-server `sslKey` and `sslCert` relative to the workspace root ([#34399](https://github.com/angular/angular-cli/pull/34399)) | + +### @angular-devkit/core + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [c44762fd44](https://github.com/angular/angular-cli/commit/c44762fd4434c63723aae0e46a023cd7b057ac4c) | fix | only invoke smart default provider when value is undefined | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------- | +| [9d6baa30ae](https://github.com/angular/angular-cli/commit/9d6baa30ae80b267ec8e5361fdb6e433ceb7a8f2) | fix | support delegating trees in `partition` ([#34539](https://github.com/angular/angular-cli/pull/34539)) | +| [5094cd66c9](https://github.com/angular/angular-cli/commit/5094cd66c9cd563b6fc2119509eb64fb15892b5d) | fix | support delegating trees in the `filter` rule ([#34539](https://github.com/angular/angular-cli/pull/34539)) | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------- | +| [65f302c5df](https://github.com/angular/angular-cli/commit/65f302c5dff0dea886c0544fdc9ba994d7d9d1c9) | fix | avoid expanding directory patterns in asset globs | +| [537a47a482](https://github.com/angular/angular-cli/commit/537a47a48295a9973a90e59c22d7b5a8f5aae4e6) | fix | escape prerender redirect URLs | +| [d609a21333](https://github.com/angular/angular-cli/commit/d609a2133354c62a368f2dc4412bbb8eb18c5831) | fix | improve library bundler resolution and watch shared chunk consistency | +| [be15160ff0](https://github.com/angular/angular-cli/commit/be15160ff0ecb7ea0c74acdead54644a2918a074) | fix | improve library compilation diagnostics and stylesheet reference tracking | +| [f25cdbeadc](https://github.com/angular/angular-cli/commit/f25cdbeadc98f64fc153684a0ff8fe8c91cef04c) | fix | include the oxc-parser WASI binding as an optional dependency | +| [70c2143c33](https://github.com/angular/angular-cli/commit/70c2143c33138476d0853370320621aff6c82f38) | fix | key the Vitest fsModuleCache on the served build output | +| [77fb7fa0f2](https://github.com/angular/angular-cli/commit/77fb7fa0f2839cdd3ee4c685e542c237b1043648) | fix | only require karma-coverage when coverage is enabled | +| [fe60e27105](https://github.com/angular/angular-cli/commit/fe60e27105b1c52a9fab11ab4d2349c7b3c093d2) | fix | resolve dev-server `sslKey` and `sslCert` relative to the workspace root ([#34399](https://github.com/angular/angular-cli/pull/34399)) | +| [9534f6287e](https://github.com/angular/angular-cli/commit/9534f6287edff39c25661e80408582a8659abf3b) | fix | scope BundlerContext invalidation and cache one-shot bundle results | +| [dd095343a5](https://github.com/angular/angular-cli/commit/dd095343a53759dc6daeabf54391ae03d000519d) | fix | serialize server manifest asset paths | +| [eb38ef3904](https://github.com/angular/angular-cli/commit/eb38ef3904e08d226c7c9c273fb9ca463fa47aa4) | fix | suggest the istanbul provider in the unit-test V8 browser coverage error | +| [cd3d30c6b6](https://github.com/angular/angular-cli/commit/cd3d30c6b6f1f9503295c4f61ea590a3c85a7e0d) | fix | warn about unsupported options in the unit-test karma runner | +| [b0c415aec0](https://github.com/angular/angular-cli/commit/b0c415aec068d2e41ec39df7674fdf61984f815f) | fix | write the stats file in a stable, deterministic order | +| [f15c8c6410](https://github.com/angular/angular-cli/commit/f15c8c6410d97b0b648ac6e8c9ef4c77e70209b6) | perf | avoid duplicate Domino bundling in server bundles | +| [7f4de14688](https://github.com/angular/angular-cli/commit/7f4de14688f55ff718a999ca929dde0fc333d950) | perf | avoid web worker channel overhead in library parallel compilation | +| [5416c9fcd9](https://github.com/angular/angular-cli/commit/5416c9fcd9eb271c2a0e0cf9e3555aa04f36d67c) | perf | optimize library dts bundling and enable dts tree-shaking | +| [016a7f150d](https://github.com/angular/angular-cli/commit/016a7f150d58ef1e268846ae0ed1cc569c4894ae) | perf | overlap library asset resolution with bundling and typechecking | + +### @angular/ssr + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [194b64137e](https://github.com/angular/angular-cli/commit/194b64137e3830cd3ca0b2236c2a44b2873e2dec) | fix | preserve trailing slashes in query and fragment of redirect URLs | + + + + + +# 22.2.5 (2026-10-28) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [7efeeb9caa](https://github.com/angular/angular-cli/commit/7efeeb9caa2def8023097d8faab405a5e536cd18) | fix | explain how to pick a project when a schematic cannot determine one | +| [ee61e328ac](https://github.com/angular/angular-cli/commit/ee61e328ac8839709ccbcc20d3ecade35e8c05f9) | fix | handle bun registry error output and 404 detection | +| [0d8898c289](https://github.com/angular/angular-cli/commit/0d8898c289cc2e92a539ad5b5bc053890bd76b3d) | fix | support resolving subproject dependencies in bun workspaces | + +### @schematics/angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [982c3dcbfc](https://github.com/angular/angular-cli/commit/982c3dcbfc248a379d0d5e5549682927bc27a719) | fix | avoid adding web worker snippet to server configuration files | +| [18e5dc6fe8](https://github.com/angular/angular-cli/commit/18e5dc6fe8f4cf54fe2aceed4c430a94ad1737db) | fix | ignore non-workspace stylesheet entries when adding Tailwind | +| [dd18bda9db](https://github.com/angular/angular-cli/commit/dd18bda9dbe267dd79ddc1f7ca435222a17733d1) | fix | include the SSR entry file in the trust-proxy-headers migration | +| [b7e24561e7](https://github.com/angular/angular-cli/commit/b7e24561e71f71a82c82f76267a5f42327d42004) | fix | include the type separator in the class spec import | +| [7f3840c70c](https://github.com/angular/angular-cli/commit/7f3840c70c11c2cbfdb23b6cfa1c65f1cd819785) | fix | keep the default getter access type of spyOnProperty in refactor-jasmine-vitest | +| [eaf2343be4](https://github.com/angular/angular-cli/commit/eaf2343be4c19a84e7525a052de6650115a11a45) | fix | migrate CallInfo returnValue and object in refactor-jasmine-vitest | +| [6680d289cb](https://github.com/angular/angular-cli/commit/6680d289cb88eee720980ce979b384a71f08216a) | fix | migrate spy.calls.first().args in refactor-jasmine-vitest | +| [9cba0da43f](https://github.com/angular/angular-cli/commit/9cba0da43f71bae7c6b01026843b2f312b25e6a5) | fix | set explicit type for the `module --route` component | +| [e9ae950364](https://github.com/angular/angular-cli/commit/e9ae95036491f7223a9989f366d17072fa4ba278) | fix | set explicit type for the app-shell component | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------- | +| [0c4bd19ace](https://github.com/angular/angular-cli/commit/0c4bd19ace75539d43cdf3e870fe557c692f382f) | fix | resolve dev-server `sslKey` and `sslCert` relative to the workspace root ([#34399](https://github.com/angular/angular-cli/pull/34399)) | + +### @angular-devkit/core + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [ad04d689bb](https://github.com/angular/angular-cli/commit/ad04d689bbcc2778d3a0a006dcf3224da2e08536) | fix | only invoke smart default provider when value is undefined | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------- | +| [e7fd1bd9cb](https://github.com/angular/angular-cli/commit/e7fd1bd9cb831e82db96f985f8cb64325b7cae3b) | fix | support delegating trees in `partition` ([#34539](https://github.com/angular/angular-cli/pull/34539)) | +| [1854c1714d](https://github.com/angular/angular-cli/commit/1854c1714d6c8ec3f66424337a4e8fabb3c30903) | fix | support delegating trees in the `filter` rule ([#34539](https://github.com/angular/angular-cli/pull/34539)) | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------- | +| [6d45d1007f](https://github.com/angular/angular-cli/commit/6d45d1007feee82d96cbf8809f20f0481087f5ef) | fix | escape prerender redirect URLs | +| [6298fb89e6](https://github.com/angular/angular-cli/commit/6298fb89e6b4de0c8228dbd9b03dd23e1d816191) | fix | include the oxc-parser WASI binding as an optional dependency | +| [325f6a0f48](https://github.com/angular/angular-cli/commit/325f6a0f4856d60142fd54abf3bb9849e84e8e71) | fix | only require karma-coverage when coverage is enabled | +| [47075bee89](https://github.com/angular/angular-cli/commit/47075bee89bc9df119f0d253f2fbfe97fd4e4725) | fix | resolve dev-server `sslKey` and `sslCert` relative to the workspace root ([#34399](https://github.com/angular/angular-cli/pull/34399)) | +| [db45eed631](https://github.com/angular/angular-cli/commit/db45eed631a9b2bbf8c6fa3e6b852cb4a9a989f9) | fix | serialize server manifest asset paths | +| [5ae6a66edb](https://github.com/angular/angular-cli/commit/5ae6a66edb33df529ba598900785f62055c1ead2) | fix | suggest the istanbul provider in the unit-test V8 browser coverage error | +| [e3134b4cbc](https://github.com/angular/angular-cli/commit/e3134b4cbc136a08e2df080ab29ae5eaca608c13) | fix | warn about unsupported options in the unit-test karma runner | +| [250fae725e](https://github.com/angular/angular-cli/commit/250fae725eb05c5ad10fd02ab8e7b09d3f880f88) | fix | write the stats file in a stable, deterministic order | +| [cda246579e](https://github.com/angular/angular-cli/commit/cda246579ea4f0f668d765ef65c9e56dab5884fe) | perf | avoid duplicate Domino bundling in server bundles | +| [bcdcc09fb7](https://github.com/angular/angular-cli/commit/bcdcc09fb706b60ad8ab0f37a0a79a65e046b6cd) | perf | avoid web worker channel overhead in library parallel compilation | + +### @angular/ssr + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [7b9c8d0b5d](https://github.com/angular/angular-cli/commit/7b9c8d0b5d39af7134191d2e2bfd717d375a1eac) | fix | preserve trailing slashes in query and fragment of redirect URLs | + + + + + +# 21.2.29 (2026-10-28) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [3fc679987f](https://github.com/angular/angular-cli/commit/3fc679987f0414a2c490d1a35ffba3f945363c98) | fix | update `webpack-dev-middleware` to `7.4.6` | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [aa2ecc154e](https://github.com/angular/angular-cli/commit/aa2ecc154e7fb591afc2e1789a4094644828f246) | fix | bump undici to 7.30.1 | +| [18164667c7](https://github.com/angular/angular-cli/commit/18164667c729cf0f51f027b88a256ce029482112) | fix | escape prerender redirect URLs | +| [8ad2547911](https://github.com/angular/angular-cli/commit/8ad25479115b77347ec6044e03a92dfbf2b05203) | fix | serialize server manifest asset paths | + + + + + +# 20.3.42 (2026-10-28) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [d6ce6ec214](https://github.com/angular/angular-cli/commit/d6ce6ec214ae35a91bdc6c03fc5697f0a69c10bc) | fix | bump undici to 7.30.1 | +| [892f3ccdfc](https://github.com/angular/angular-cli/commit/892f3ccdfc4dbe0c65ef70760d9018e14e174e41) | fix | update `webpack-dev-middleware` to `7.4.6` | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------ | +| [223891de4c](https://github.com/angular/angular-cli/commit/223891de4c43315fe0d863e1ffacf7395a30abc7) | fix | escape prerender redirect URLs | + + + + + +# 22.3.0-next.3 (2026-10-22) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [d69dcec31](https://github.com/angular/angular-cli/commit/d69dcec3137f0fd49e591d410c82569b3798f2ec) | fix | keep schematic positional arguments in index order | +| [21846daf3](https://github.com/angular/angular-cli/commit/21846daf3637496e5f4f86c95843300df871c4dd) | fix | only load autocompletion in interactive shells | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [1c1fe4e88](https://github.com/angular/angular-cli/commit/1c1fe4e88897f69116696a73c46f225b469aebab) | fix | apply `viewEncapsulation` to generated application components | +| [32dd079da](https://github.com/angular/angular-cli/commit/32dd079da3fd6b53fe6d57eaba0ee2bcfb8f3b08) | fix | correct error and warning messages in environments schematic | +| [9732721b0](https://github.com/angular/angular-cli/commit/9732721b0e35bd134240e1f5d316f813bd6ed9c3) | fix | migrate ng-packagr glob string assets in use-library-builder | +| [c32a690f1](https://github.com/angular/angular-cli/commit/c32a690f1e82230a695d6d349f5e7b68e6a07ffe) | fix | migrate secondary entry point style options in use-library-builder | +| [490a6fe2f](https://github.com/angular/angular-cli/commit/490a6fe2f1938ea3da5299dabf3a41dec4ef8399) | fix | show routing module suffixes in `findModule` error | +| [41b54ecdd](https://github.com/angular/angular-cli/commit/41b54ecdda17b96924d82873042c04dc7d3a4e40) | fix | skip `webWorkerTsConfig` for unit-test builder test targets | +| [b9163f3a5](https://github.com/angular/angular-cli/commit/b9163f3a559cf2c45c77dc2a50198b5cc6c3d66f) | fix | use the generated class name when declaring in an NgModule | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [5f65028b1](https://github.com/angular/angular-cli/commit/5f65028b1928bcfc95b1581f754e93a5456bda63) | feat | add `prerender.format` option to prerender routes as `<route>.html` | +| [089d39467](https://github.com/angular/angular-cli/commit/089d39467863643ecadf79cefc5c92a16b0a85d3) | fix | add integrity metadata for all initial chunks beyond the module preload limit | +| [1b893ed95](https://github.com/angular/angular-cli/commit/1b893ed95ba3bfe0690274682c6473489003dbe8) | fix | align lazy route entry names with workspace root for SSR modulepreload | +| [a38958c28](https://github.com/angular/angular-cli/commit/a38958c289663a90791e8d096963d2bd8dec802c) | fix | align library watch asset matching with initial build and deduplicate outputs | +| [3fcf6c852](https://github.com/angular/angular-cli/commit/3fcf6c85237c13484d190455521e73569ebb4191) | fix | attach location to Sass errors and guard stack parsing | +| [97efdcf91](https://github.com/angular/angular-cli/commit/97efdcf9136230baeb9ec757dcd24a17f12cd223) | fix | consolidate initial shared chunks into main with Rolldown | +| [13a734649](https://github.com/angular/angular-cli/commit/13a73464935365ed48bf30ea520bcc4c84828b9e) | fix | correct typos and spacing in error and warning messages | +| [566abde88](https://github.com/angular/angular-cli/commit/566abde8866d0512f89953dc5b6a0aae6ef63db8) | fix | enforce library compilerOptions and skip unused .tsbuildinfo emit | +| [f696eaaf8](https://github.com/angular/angular-cli/commit/f696eaaf8a233c83981c5de01c8568074c575edd) | fix | improve library watch mode state invalidation and lazy asset matchers | +| [adcdcc668](https://github.com/angular/angular-cli/commit/adcdcc6688fd773491dcb9f5197eefffd4aeee45) | fix | invalidate Vite module cache for removed dev server output files | +| [f9f386497](https://github.com/angular/angular-cli/commit/f9f3864974e90c6ea78c53cc4a0cab3f2e4477dc) | fix | match proxy glob patterns against URL pathname only | +| [f9325ab02](https://github.com/angular/angular-cli/commit/f9325ab02da065ea5b4cf81fb5f83ba56de5c50d) | fix | name the provider packages in the unit-test `browsers` error | +| [193abc0c1](https://github.com/angular/angular-cli/commit/193abc0c15621f28b8d12866e22711138c3339f6) | fix | preserve asset precedence and expand glob-prefixed ignores in watch mode | +| [cd70beb20](https://github.com/angular/angular-cli/commit/cd70beb20db711cb178afccf6c688f9475f9e1f9) | fix | preserve diagnostic locations at offset 0 and guard empty files | +| [faca2b0f0](https://github.com/angular/angular-cli/commit/faca2b0f035e22fff421d353d6b51af1f6aec9d6) | fix | preserve entry-point displayName casing and flatten bundle names | +| [137351b7d](https://github.com/angular/angular-cli/commit/137351b7de55c899cea9108554777e020de52851) | fix | preserve incremental .d.ts signatures and emit state in watch mode | +| [c3881e9eb](https://github.com/angular/angular-cli/commit/c3881e9eba39436faed4879342de9fd9541ea207) | fix | report TypeScript declaration emit diagnostics | +| [b8180a653](https://github.com/angular/angular-cli/commit/b8180a6530cfb7a39515de016a5386ec48ef1fdb) | fix | support incremental asset updates and output cleanup in library watch mode | +| [e3787076e](https://github.com/angular/angular-cli/commit/e3787076ebca0af72079f669b5b047a065b16128) | fix | support quotes in `styles` and `polyfills` paths | +| [8c1498395](https://github.com/angular/angular-cli/commit/8c1498395a3576f397581a4629dfd15e83cae819) | fix | use component file path in inline stylesheet diagnostic locations | +| [e93d9cb8b](https://github.com/angular/angular-cli/commit/e93d9cb8bb8d9902bbe437e9dece8f44cb859bfe) | fix | use distinct chunk naming for web worker bundles | +| [58c30c05f](https://github.com/angular/angular-cli/commit/58c30c05f6f60d3a985921f411354705f12f611d) | fix | use isSubDirectory in normalizeAssetPatterns and ignore nested .gitkeep | +| [9e998c128](https://github.com/angular/angular-cli/commit/9e998c12862c2bd7a70a088ea6758aefb23a698e) | fix | validate library outputPath and avoid duplicate secondary README assets | +| [972302ac3](https://github.com/angular/angular-cli/commit/972302ac3dc7b675e4b36dac465b876014a02b1e) | fix | watch only explicitly added paths in chokidar watcher | +| [5691298d0](https://github.com/angular/angular-cli/commit/5691298d035b8d965d73d3a2282436ada441ec90) | perf | avoid reading external component stylesheets twice | +| [6bae97bd4](https://github.com/angular/angular-cli/commit/6bae97bd498ac602e71e33daa8aa0f57a37f7347) | perf | avoid TS AST eviction and d.ts re-emit on external resource edits | +| [751adc3ab](https://github.com/angular/angular-cli/commit/751adc3abc664616dea333667e60910916c6334b) | perf | cache pre-declaration transformed SourceFiles during AoT emit | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------- | +| [8aa16b6f9](https://github.com/angular/angular-cli/commit/8aa16b6f9fe5bb1301438bfb4625a7b6c68a5392) | fix | resolve query parameter placeholders in redirect targets ([#34321](https://github.com/angular/angular-cli/pull/34321)) | +| [97ed36ec4](https://github.com/angular/angular-cli/commit/97ed36ec4f0423258cb949769c2fb6557054daa3) | fix | resolve route parameters in redirect targets ([#34321](https://github.com/angular/angular-cli/pull/34321)) | +| [b42cfeecf](https://github.com/angular/angular-cli/commit/b42cfeecfac376d1ed365ffbf9d247dbfc2bde59) | fix | use only the first value of the `X-Forwarded-Prefix` header | + + + + + +# 21.2.28 (2026-10-22) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [225d00adb4](https://github.com/angular/angular-cli/commit/225d00adb469a23a8b14bb33dff9a14595f8ca8c) | fix | bump @modelcontextprotocol/sdk to 1.32.1 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [651d1d293d](https://github.com/angular/angular-cli/commit/651d1d293dd47454d76f2f7ddbc70f7a5607366b) | fix | update webpack to 5.112.0 | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------ | +| [a081d8b6db](https://github.com/angular/angular-cli/commit/a081d8b6dbef26d33cb22b4b40c9a843a2b87047) | fix | bump vite to 7.3.7 | + + + + + +# 20.3.41 (2026-10-22) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [36fbb892b4](https://github.com/angular/angular-cli/commit/36fbb892b48f6313de1946cf32bc6fb4140eb959) | fix | bump @modelcontextprotocol/sdk to 1.32.1 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [f905af981b](https://github.com/angular/angular-cli/commit/f905af981bb1f812944dfa31a623b7fcbb08f8e9) | fix | update webpack to 5.112.0 | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------ | +| [a1e3016f1e](https://github.com/angular/angular-cli/commit/a1e3016f1efe65b49b9d6affd13e6fe1d95607e4) | fix | bump vite to 7.3.7 | + + + + + +# 22.2.4 (2026-10-22) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [63e45bcfe](https://github.com/angular/angular-cli/commit/63e45bcfe711b6e2320db7ee24caba898bb90347) | fix | keep schematic positional arguments in index order | +| [6053771cb](https://github.com/angular/angular-cli/commit/6053771cba3c29a481d21ea6a2f2b9dd279b6db2) | fix | only load autocompletion in interactive shells | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [eec8dc049](https://github.com/angular/angular-cli/commit/eec8dc049a21e8ee685dd07ed365e43d04802cf5) | fix | apply `viewEncapsulation` to generated application components | +| [3a549925e](https://github.com/angular/angular-cli/commit/3a549925e2adf2101ae635da1e4a9cf0217c13d2) | fix | correct error and warning messages in environments schematic | +| [fe119a7c7](https://github.com/angular/angular-cli/commit/fe119a7c717db7a50d92073dd8d3a2f4726bbd6f) | fix | show routing module suffixes in `findModule` error | +| [36d988001](https://github.com/angular/angular-cli/commit/36d988001e1d36d02a759f2580bd251fff175e07) | fix | skip `webWorkerTsConfig` for unit-test builder test targets | +| [460167cd3](https://github.com/angular/angular-cli/commit/460167cd335b6b4addb9c92d60f56a04ddf9ed5e) | fix | use the generated class name when declaring in an NgModule | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [c235a9554](https://github.com/angular/angular-cli/commit/c235a9554fb1f5cad734a3fb2df7c4f7e4283f91) | fix | add integrity metadata for all initial chunks beyond the module preload limit | +| [80c7480b4](https://github.com/angular/angular-cli/commit/80c7480b4d574ef2cd178e315288d9bfe821a5ac) | fix | align lazy route entry names with workspace root for SSR modulepreload | +| [b94627706](https://github.com/angular/angular-cli/commit/b9462770643465bd1bf33bc7cdaf2386d0bf9d41) | fix | attach location to Sass errors and guard stack parsing | +| [0dc103735](https://github.com/angular/angular-cli/commit/0dc103735e9cd42007a0b81cf1c2a563f80b94e6) | fix | correct typos and spacing in error and warning messages | +| [3e464c3f5](https://github.com/angular/angular-cli/commit/3e464c3f55d4aa2b7f1cb3d62ee3104c62029a5e) | fix | invalidate Vite module cache for removed dev server output files | +| [7d9ba6280](https://github.com/angular/angular-cli/commit/7d9ba628034866a2ec51a6357f05c9ce426cbb76) | fix | match proxy glob patterns against URL pathname only | +| [3aa7e93cc](https://github.com/angular/angular-cli/commit/3aa7e93ccf898d84d620ff0b46e5a718fa9c3a90) | fix | name the provider packages in the unit-test `browsers` error | +| [e78cf0ae9](https://github.com/angular/angular-cli/commit/e78cf0ae9676e77245de3cae7ae86b42a48a6322) | fix | preserve diagnostic locations at offset 0 and guard empty files | +| [b3baa20c6](https://github.com/angular/angular-cli/commit/b3baa20c6cb5b00c9df4d3d4d072435b324566e2) | fix | support quotes in `styles` and `polyfills` paths | +| [648aff9eb](https://github.com/angular/angular-cli/commit/648aff9ebb54735fab5cc4265200a06c4d531f35) | fix | use component file path in inline stylesheet diagnostic locations | +| [a9bec13b5](https://github.com/angular/angular-cli/commit/a9bec13b58550fc757612c3617aa33eefb776041) | fix | use distinct chunk naming for web worker bundles | +| [a05723948](https://github.com/angular/angular-cli/commit/a057239486e42ec7189f03a61b09e3b0761150c9) | fix | watch only explicitly added paths in chokidar watcher | +| [35702a598](https://github.com/angular/angular-cli/commit/35702a5986dc7ea4f2f2d7f18ef9f8f3b4fd33c2) | perf | avoid reading external component stylesheets twice | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------- | +| [3beb2551c](https://github.com/angular/angular-cli/commit/3beb2551cc784ed0714070bbd23c1a8bb70e2938) | fix | resolve query parameter placeholders in redirect targets ([#34321](https://github.com/angular/angular-cli/pull/34321)) | +| [ff3f3727a](https://github.com/angular/angular-cli/commit/ff3f3727a9eb18f0b7714646a04b7d3e172c18d7) | fix | resolve route parameters in redirect targets ([#34321](https://github.com/angular/angular-cli/pull/34321)) | +| [d365b0008](https://github.com/angular/angular-cli/commit/d365b0008f0c5bb8bca92e83066999253f409b64) | fix | use only the first value of the `X-Forwarded-Prefix` header | + + + + + +# 22.3.0-next.2 (2026-10-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [5c656152e](https://github.com/angular/angular-cli/commit/5c656152ef91e2731155f6027a845d8014b53e4f) | fix | accept multi-digit array indices in ng config | +| [2e0f48e3f](https://github.com/angular/angular-cli/commit/2e0f48e3ff40289725d3590fa8e1fc059fbaeb88) | fix | keep ng version working when the package manager is not installed | +| [a1cf33bc4](https://github.com/angular/angular-cli/commit/a1cf33bc45882fb74578cc7d1faeba8cecec7b73) | fix | resolve package name in npm workspace members | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [639d31f11](https://github.com/angular/angular-cli/commit/639d31f11b102a12776ae8dce6369d6edd1b6ff3) | feat | add use-library-builder migration to migrate from ng-packagr | +| [445cc767e](https://github.com/angular/angular-cli/commit/445cc767e62b72631dd53c0c5324b458fee76608) | fix | keep the workspace build version in the use-application-builder migration | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [b39a5fcda](https://github.com/angular/angular-cli/commit/b39a5fcdac288e317f3b00468c09173a1d306f98) | fix | avoid stack overflow when chaining many rules | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------- | +| [b975da4b0](https://github.com/angular/angular-cli/commit/b975da4b016eaa107acc259eb8205135d9396f95) | fix | add CSP nonce to stylesheet and modulepreload link elements | +| [13923047f](https://github.com/angular/angular-cli/commit/13923047f64b6e1063f7f07029c70295b5c99d69) | fix | always use AOT when extracting i18n messages | +| [d2233e225](https://github.com/angular/angular-cli/commit/d2233e225aa5fd507e3ce7bfd35ae2268c036aa4) | fix | coalesce external watcher directories to avoid macOS FSEvents limit | +| [23d42fc80](https://github.com/angular/angular-cli/commit/23d42fc806f174c7da961c35e07a42aa1e00d1e7) | fix | conditionally declare tslib in built library package | +| [f0a74e8f0](https://github.com/angular/angular-cli/commit/f0a74e8f0ea04fd3f839be0120346067440b1350) | fix | disable Vitest fsModuleCache in unit-test builder | +| [2c518a6db](https://github.com/angular/angular-cli/commit/2c518a6dbe8734d247dfe4eb2c1c4af5e169fa94) | fix | enhance build logs and output location for library builder | +| [47c328d3e](https://github.com/angular/angular-cli/commit/47c328d3ef7b409af02f270a48f5e99a520fbe14) | fix | ensure Sass importers return global Promise instances | +| [432aea016](https://github.com/angular/angular-cli/commit/432aea0161840bc135704c040108e46092e61c27) | fix | filter Rolldown warnings and disable .d.ts annotation comments in library builder | +| [39d230e18](https://github.com/angular/angular-cli/commit/39d230e1862f2f626d3fba03bbf51873f3578153) | fix | keep Vite server file watcher disabled in Vitest watch mode | +| [93f238c97](https://github.com/angular/angular-cli/commit/93f238c9781897997b1c64d607f3bb3c91ba1fd1) | fix | rebuild library when transitive stylesheet dependencies change in watch mode | +| [7714a8537](https://github.com/angular/angular-cli/commit/7714a8537589afc6180443cc36acd5149a459c57) | fix | resolve unimported NgModule scopes when filtering unit-test files | +| [ad4845583](https://github.com/angular/angular-cli/commit/ad4845583b8d1b8de26ccc0e45b4e6e58d95b362) | fix | respect tsconfig declarationMap and compilationMode in library builder | +| [01afbf418](https://github.com/angular/angular-cli/commit/01afbf418cf6a7688f3f131988eeba3cdac2bde4) | fix | sort discovered unit-test files for deterministic builds | +| [b640b32e1](https://github.com/angular/angular-cli/commit/b640b32e148ab68f820b390b1d9f03ae9a809266) | fix | use module.registerHooks() for in-memory ESM loader | +| [0a077eeed](https://github.com/angular/angular-cli/commit/0a077eeed92b43fa195bf10f479d39a7aca2d3f7) | perf | avoid invalidating Angular diagnostic cache on stylesheet change | +| [2b5a9a620](https://github.com/angular/angular-cli/commit/2b5a9a620495b4c85bf5cfa3db39b632d88e6ca5) | perf | enable TypeScript incremental build cache in library builder | +| [07845f019](https://github.com/angular/angular-cli/commit/07845f019cf7d70c17f037d711b430a776f96031) | perf | optimize incremental watch rebuilds in AOT compilation | +| [fb7efc862](https://github.com/angular/angular-cli/commit/fb7efc86228164330f68ed86801f9d5bbe9ad5b0) | perf | resolve asset patterns concurrently with bounded concurrency | +| [dcf906a62](https://github.com/angular/angular-cli/commit/dcf906a6261a00f95cfbdb18cf9065e1c966e182) | perf | reuse extendedConfigCache in TypeScript compilation | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [0b9a52cc9](https://github.com/angular/angular-cli/commit/0b9a52cc9516d28afa0d9ef0d07ec745967faf56) | fix | preserve preload metadata for nested empty-path routes | +| [f2cd8c8cf](https://github.com/angular/angular-cli/commit/f2cd8c8cf7c5aa7e7076c87e9c0c55b3bf880f4b) | fix | preserve query parameters on relative route redirects | +| [b3bbada1e](https://github.com/angular/angular-cli/commit/b3bbada1efd0d3a414cce8843b7284e9560f66fc) | fix | resolve relative redirects against the parent route | + + + + + +# 22.2.3 (2026-10-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [3158e865f](https://github.com/angular/angular-cli/commit/3158e865ffdd828711d645dff16cc17fd26bb58e) | fix | accept multi-digit array indices in ng config | +| [bcff0e76a](https://github.com/angular/angular-cli/commit/bcff0e76adf73cfa32b02c5256490189675cb132) | fix | keep ng version working when the package manager is not installed | +| [5570c91d2](https://github.com/angular/angular-cli/commit/5570c91d2f38764cd841fcce146f1d13fef49b0c) | fix | resolve package name in npm workspace members | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [881865f36](https://github.com/angular/angular-cli/commit/881865f364b33b36630aa6ab7a129f7ed665cfa7) | fix | keep the workspace build version in the use-application-builder migration | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [cf1f3b9d4](https://github.com/angular/angular-cli/commit/cf1f3b9d43177e15cf9a00973e6da3baeaf4b2dd) | fix | avoid stack overflow when chaining many rules | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [1d477d6bc](https://github.com/angular/angular-cli/commit/1d477d6bc27159cb0c78288e047fcadfe6fc31d4) | fix | add CSP nonce to stylesheet and modulepreload link elements | +| [fbe208f83](https://github.com/angular/angular-cli/commit/fbe208f8392ec0ce82d597e8b690f146370bdc81) | fix | always use AOT when extracting i18n messages | +| [c1c4216a6](https://github.com/angular/angular-cli/commit/c1c4216a680891b80670660a621d6736c4aecaf0) | fix | coalesce external watcher directories to avoid macOS FSEvents limit | +| [792990bc2](https://github.com/angular/angular-cli/commit/792990bc2eacdcc8ed4de6ef46e58ceeb5fe8741) | fix | disable Vitest fsModuleCache in unit-test builder | +| [82439399c](https://github.com/angular/angular-cli/commit/82439399cbfb4c1d21d1c80d976021efa9e4a39a) | fix | ensure Sass importers return global Promise instances | +| [a818de415](https://github.com/angular/angular-cli/commit/a818de415e933819e7a2e3e679cd0b2d2be734de) | fix | keep Vite server file watcher disabled in Vitest watch mode | +| [91bad469e](https://github.com/angular/angular-cli/commit/91bad469ec5ce7b2dfd07e394b482d2cef411407) | fix | sort discovered unit-test files for deterministic builds | +| [e84db4af7](https://github.com/angular/angular-cli/commit/e84db4af70ca9be490b462e6d8112524dc8eb6d4) | fix | use module.registerHooks() for in-memory ESM loader | +| [a428021b7](https://github.com/angular/angular-cli/commit/a428021b77dbccf404f39712f1c88bed9b8e4272) | perf | avoid invalidating Angular diagnostic cache on stylesheet change | +| [b0ac394a0](https://github.com/angular/angular-cli/commit/b0ac394a063f084d885df67660224b6a25e2c614) | perf | resolve asset patterns concurrently with bounded concurrency | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [bd09b98fc](https://github.com/angular/angular-cli/commit/bd09b98fcaff4b76c7fd65999b51bfaa942d1858) | fix | preserve preload metadata for nested empty-path routes | +| [a92546892](https://github.com/angular/angular-cli/commit/a925468929c74c30a3e64b361a6dc5d6fdc1006a) | fix | preserve query parameters on relative route redirects | +| [a2b86cc94](https://github.com/angular/angular-cli/commit/a2b86cc94d408ba4c3d422224b58e26b7a68f692) | fix | resolve relative redirects against the parent route | + + + + + +# 21.2.27 (2026-10-14) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------- | +| [2e1742adf](https://github.com/angular/angular-cli/commit/2e1742adf28645635bb6cff3cc08ad9c94c7d085) | fix | update zod to 4.6.6 | + + + + + +# 20.3.40 (2026-10-14) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------- | +| [bfe1908f4](https://github.com/angular/angular-cli/commit/bfe1908f46f5799e839dcf0f162955df59a94cb0) | fix | update zod to 4.6.6 | + + + + + +# 22.3.0-next.1 (2026-10-07) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [3379a13314](https://github.com/angular/angular-cli/commit/3379a133148825a6ecb5eeacafdd6bcfd3c8e50b) | fix | preserve Host header when proxying in ssr-dev-server | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [4244eb7ba1](https://github.com/angular/angular-cli/commit/4244eb7ba1be5371dc8eab0c8ee07a8269ce09aa) | fix | allow library outputPath within project root | +| [f61de66202](https://github.com/angular/angular-cli/commit/f61de6620271d71bb7fc051d0ea03426bfa2af5b) | fix | capture and report Rolldown warnings and errors in library builder | +| [3871a9da91](https://github.com/angular/angular-cli/commit/3871a9da9106aefc4b10095d4716e30db09fd864) | fix | keep the order of glob entries in the proxy configuration | +| [3d00c76eb9](https://github.com/angular/angular-cli/commit/3d00c76eb9c5ea38d1ede08e65ed0ea22f6f0232) | fix | resolve library bundle sourcemap sources relative to output directory | +| [5ffed3f3b6](https://github.com/angular/angular-cli/commit/5ffed3f3b6b39583cda1e35854cc001344ed6eba) | fix | scope cache directory by project and builder name | +| [1e45d4d46a](https://github.com/angular/angular-cli/commit/1e45d4d46ad79ad890fe973b5fe697664826cc2d) | fix | support a string `context` in array-form proxy configurations | +| [2ffbebf24c](https://github.com/angular/angular-cli/commit/2ffbebf24c0b12c14490f98a1df0448031bd105d) | perf | immediately purge freed rolldown arena memory | + + + + + +# 22.2.2 (2026-10-07) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [cfe970c8bc](https://github.com/angular/angular-cli/commit/cfe970c8bcfbc47e9fa1fa6b17e41cdf1d3b30bf) | fix | preserve Host header when proxying in ssr-dev-server | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [405bfa370a](https://github.com/angular/angular-cli/commit/405bfa370ac3c8f14808d58cfc707b7ff23348c4) | fix | keep the order of glob entries in the proxy configuration | +| [996e47f809](https://github.com/angular/angular-cli/commit/996e47f809a3172486df665bfce6d5c11e652154) | fix | support a string `context` in array-form proxy configurations | + + + + + +# 21.2.26 (2026-10-07) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [9d2987f354](https://github.com/angular/angular-cli/commit/9d2987f354df7718ec76992cf28a0ab6cbf96539) | fix | bump @modelcontextprotocol/sdk to 1.31.0 | + + + + + +# 20.3.39 (2026-10-07) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [373a7cedce](https://github.com/angular/angular-cli/commit/373a7cedce59662f6db6f5361192b6158377e01d) | fix | bump @modelcontextprotocol/sdk to 1.31.0 | + + + + + +# 21.2.25 (2026-10-05) + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [7e6d2da084](https://github.com/angular/angular-cli/commit/7e6d2da08406f240b18b1d7e692cad27f29af314) | fix | bump piscina to 5.3.2 | + + + + + +# 20.3.38 (2026-10-05) + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [3e39e33fb1](https://github.com/angular/angular-cli/commit/3e39e33fb1422af144ea795d871969640a27627b) | fix | bump piscina to 5.3.2 | + + + + + +# 22.3.0-next.0 (2026-10-01) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [f52fb536a1](https://github.com/angular/angular-cli/commit/f52fb536a1fddabee42448a050961c7986314992) | fix | decouple official Node.js support check from execution gate | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [c5b8bf0369](https://github.com/angular/angular-cli/commit/c5b8bf036965f31c283b1d14bd02b31bdfc02353) | feat | add library builder | +| [feb41cc934](https://github.com/angular/angular-cli/commit/feb41cc9341ff001a67ffc22ac9816aba8750e65) | feat | add polyfills option to unit-test builder | +| [d09f98ee70](https://github.com/angular/angular-cli/commit/d09f98ee701754bdb4eca7d56dcf506c8d8b6729) | fix | avoid top-level await for Zone.js injection in Vitest runner | +| [a0a6b422cd](https://github.com/angular/angular-cli/commit/a0a6b422cdc3ce4e79f5b8eaccf1e9ed82579afe) | fix | ensure asset followSymlinks defaults to false | +| [69acf5b822](https://github.com/angular/angular-cli/commit/69acf5b822bebc0b4bebd30f1db6cc7b8d624531) | fix | include dataurl option in global stylesheet config hash | +| [76dd17d522](https://github.com/angular/angular-cli/commit/76dd17d522751b91ff46d42c1eb11bb0f5cb1be9) | fix | re-analyze exports on package.json change in library builder | + + + + + +# 22.2.1 (2026-10-01) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [db053444e0](https://github.com/angular/angular-cli/commit/db053444e0af22223100f931def9f41d1eea4426) | fix | skip components with a change detection strategy in the zoneless migration tool | + +### @schematics/angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [663c580c86](https://github.com/angular/angular-cli/commit/663c580c86645b5b3a31925154427588e48018ba) | fix | add browsers option to angular.json during vitest browser provider ng-add | + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------ | +| [e6350b6f57](https://github.com/angular/angular-cli/commit/e6350b6f57a546f77d38515604d1c1fa46d85d39) | fix | correctly parse inline boolean values in CLI options ([#34201](https://github.com/angular/angular-cli/pull/34201)) | + +### @angular-devkit/architect + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------ | +| [516df4ee6d](https://github.com/angular/angular-cli/commit/516df4ee6d1d07ad8b851ff108595d08ef4d319b) | fix | correctly parse inline boolean values in CLI options ([#34201](https://github.com/angular/angular-cli/pull/34201)) | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [ac70f86208](https://github.com/angular/angular-cli/commit/ac70f862086343d2d9a36fd7b09b3a3680e907b0) | fix | do not mark standalone expression statements as pure | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------- | +| [ec9c0b51c2](https://github.com/angular/angular-cli/commit/ec9c0b51c24fd0fba8a7f01dbb0ff25cf520a0d5) | fix | deduplicate spurious chokidar polling change events ([#34188](https://github.com/angular/angular-cli/pull/34188)) | +| [c1b5154180](https://github.com/angular/angular-cli/commit/c1b515418033685a9624140b5eecb6330ec07504) | fix | encode script type in auto-CSP loader | +| [cb4dd71a1b](https://github.com/angular/angular-cli/commit/cb4dd71a1bbf9d7a8273061c54a8997cb725e9e3) | fix | ensure @angular/compiler is loaded for Vitest TestBed initialization | +| [b198044048](https://github.com/angular/angular-cli/commit/b1980440484d809f89e54d04bdeb484ff2db6aae) | fix | preserve nested workspace path for git worktree cache | +| [5194b41531](https://github.com/angular/angular-cli/commit/5194b415314a4fb2435b29dcf42ccffbc26070a9) | fix | prevent chokidar from scanning unimported node_modules in watch mode | +| [37cf399d95](https://github.com/angular/angular-cli/commit/37cf399d95d9376811cd79144bbd517f3efe3d32) | fix | reset component updates on dev-server SSR page request | +| [ddff42cb03](https://github.com/angular/angular-cli/commit/ddff42cb03cd45f1e668794057843f8a98501c0a) | fix | serve emitted HTML assets in dev-server | +| [e3e893682d](https://github.com/angular/angular-cli/commit/e3e893682d501d3135bf3abcb1c74b9598f78017) | fix | watchers outside the workspace via parcel didn't work | +| [d55e899954](https://github.com/angular/angular-cli/commit/d55e899954e677c9d0751ab8e56b0364bf3a3098) | perf | avoid loading compiler-cli and typescript in js transform worker | +| [3d08e5b053](https://github.com/angular/angular-cli/commit/3d08e5b053d50f9101215974039aafcc39f6a9e9) | perf | avoid sharding intermediate files under worker surplus in i18n inliner | +| [5ea36fe8b8](https://github.com/angular/angular-cli/commit/5ea36fe8b8b9732054a3749912018bae71e3b746) | perf | bound aggregate worker allocation across dominant files in i18n inliner | +| [90ee193bd1](https://github.com/angular/angular-cli/commit/90ee193bd188629da762e2f98cb664d8493ed733) | perf | bound concurrent worker allocation for dominant files in i18n inliner | +| [41b353856b](https://github.com/angular/angular-cli/commit/41b353856ba0babc03907701508d7a2b45c96743) | perf | process batch locales sequentially in i18n inliner worker | + + + + + +# 22.2.0 (2026-09-23) + +## Deprecations + +### @angular/build + +- The `splitting` option in the unit-test builder is deprecated as it is no longer needed with Vitest 5. + + Disabling code splitting is no longer necessary with Vitest 5, as the issue where shared-module exports were uninitialized in class-field initializers under JSDOM has been resolved. + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [41555dfb3](https://github.com/angular/angular-cli/commit/41555dfb3b71d08cdfe2853bf2cbeca5b6942f67) | feat | add `--root` command line option to `mcp` command | +| [6e7f809a7](https://github.com/angular/angular-cli/commit/6e7f809a7d66e4b8d260a1c00297812412a926f1) | perf | skip eager yargs help message formatting during command execution | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------- | +| [ad8271320](https://github.com/angular/angular-cli/commit/ad8271320f67c2b1502e446f1e3cb0a05444d04f) | feat | migrate Angular Linker to oxc-parser and magic-string | +| [175273931](https://github.com/angular/angular-cli/commit/175273931ebb0cf08bf62dd1694dbde5ff5229b6) | feat | Support splitting browser and server stats jsonfiles for easier consumption | +| [fe2f180fd](https://github.com/angular/angular-cli/commit/fe2f180fd0c2fa02706353ca6a034d235feb6c59) | fix | add automatic corruption recovery in SQLite cache store | +| [675f1f527](https://github.com/angular/angular-cli/commit/675f1f5274ff853f153832f7adc69273fa63f0cd) | fix | avoid pure comment pattern in oxc transform | +| [d396324b4](https://github.com/angular/angular-cli/commit/d396324b4eb3b16640526a64365d953642bc7ca1) | fix | default preloadInitial to false when serviceWorker is enabled | +| [7e5510e46](https://github.com/angular/angular-cli/commit/7e5510e464eb423d8757ae6f058f73fc17249d81) | fix | disable linker internal source mapping in OXC linker | +| [8504cfffc](https://github.com/angular/angular-cli/commit/8504cfffcfb943fcd8fda62507e4b236e4bf429a) | fix | disable root config extension for inline test project | +| [ee3041325](https://github.com/angular/angular-cli/commit/ee3041325cd2400c26e239e64e14a0e314ab6031) | fix | ensure chokidar watcher is ready before returning | +| [54d712f57](https://github.com/angular/angular-cli/commit/54d712f57904d2f612eec27a0507ea298b40915e) | fix | ensure parent directory exists in SQLite cache store | +| [80acd6236](https://github.com/angular/angular-cli/commit/80acd6236eac6b1b07543583717e94cbd1de1180) | fix | execute setup file hooks for each spec file when coverage is enabled | +| [978351af7](https://github.com/angular/angular-cli/commit/978351af71889d4786a18fec9e193eb5b16af7dd) | fix | include inline component stylesheets in referenced watch files | +| [f47f77f5f](https://github.com/angular/angular-cli/commit/f47f77f5f6db5cda1493652f813c98c26f172ea9) | fix | only compile included test files in unit-test builder | +| [72dc9677c](https://github.com/angular/angular-cli/commit/72dc9677cf743243d6cb22d2b81782022f71356e) | fix | pass load cache to compiler plugin and escape extension regex in polyfills | +| [701bde481](https://github.com/angular/angular-cli/commit/701bde4818439de0e49c99d57dc749b0dd22c4dd) | fix | preserve duplicate bundle filenames across output file types | +| [e4111ae81](https://github.com/angular/angular-cli/commit/e4111ae8180f3cd5800f248c23010844aa1730ad) | fix | prevent memory leaks in parallel compilation and caches | +| [31c045639](https://github.com/angular/angular-cli/commit/31c045639e8bfd114cbd552d83cc70a968fe7f81) | fix | prevent stale bundler caching and correctly resolve load cache | +| [bad31421c](https://github.com/angular/angular-cli/commit/bad31421cbf23339d4fbd389a91d198df405697d) | fix | prevent syntax corruption in linker and oxc transform | +| [0d9851600](https://github.com/angular/angular-cli/commit/0d9851600ca618ccabfc3143a9c552a5448d6eec) | fix | recursively ignore output and cache paths in watch mode | +| [040bc21ec](https://github.com/angular/angular-cli/commit/040bc21ec3d4fa1bdfa8513206f463d89e20d9a1) | fix | scope Sass package resolution caching for stylesheets in node_modules | +| [1fc273279](https://github.com/angular/angular-cli/commit/1fc2732795069ad521645b06a94e5df3a55c3d9c) | fix | support parenthesized expressions in oxc linker | +| [b423bc39c](https://github.com/angular/angular-cli/commit/b423bc39cd59c73ae79402c6cccdc0ecf1d7aaa1) | fix | use chokidar watcher when followSymlinks is enabled | +| [87551ad5c](https://github.com/angular/angular-cli/commit/87551ad5c53f90cbdc68fdb5e47007b9436c7f75) | perf | avoid encoding intermediate source maps before remapping | +| [6ee559ca9](https://github.com/angular/angular-cli/commit/6ee559ca9d209fb4c53de2aaf9918d10e5f062a1) | perf | bypass worker dispatch for files without transform candidates | +| [0589edd9c](https://github.com/angular/angular-cli/commit/0589edd9cdc493ecf34a160cc7b8491618b59f72) | perf | consolidate component stylesheet bundling with shared load result cache | +| [482f6d8aa](https://github.com/angular/angular-cli/commit/482f6d8aab289fbd6dc5f9aecdcf7b8e1b6cac4f) | perf | decouple diagnostic type checking from build start | +| [a877aa7eb](https://github.com/angular/angular-cli/commit/a877aa7ebdbf919d6bc17e6ec0920ca702db0db4) | perf | implement SharedArrayBuffer translation dictionaries for zero-copy worker access | +| [f1cc95943](https://github.com/angular/angular-cli/commit/f1cc959439d8ca11a45d693520c34dd94e0b6548) | perf | implement sliding-window batching and worker translation eviction | +| [d97c8857c](https://github.com/angular/angular-cli/commit/d97c8857c18da02fcc6c69a0ea652cc706c5c9b6) | perf | key Sass package resolutions without containing URL qualification | +| [5cd20e141](https://github.com/angular/angular-cli/commit/5cd20e141ad176c87e4002da2d4ee3755f97cd3e) | perf | only reset Sass worker pool caches when stylesheet files change | +| [1c00edce0](https://github.com/angular/angular-cli/commit/1c00edce02bdd0201546417d71518f78bfb89d34) | perf | optimize sourcemap stripping and loading with buffer fast path | +| [0ba4d8e29](https://github.com/angular/angular-cli/commit/0ba4d8e29c0fb9abe6b07432edfab9e39066ca29) | perf | reduce watcher debounce latency for faster incremental rebuilds | +| [a6ef9cfbe](https://github.com/angular/angular-cli/commit/a6ef9cfbeace725d58c0f7f65640ef6de9b39c33) | perf | replace watchpack with @parcel/watcher and chokidar | +| [4d11a1334](https://github.com/angular/angular-cli/commit/4d11a1334ff5fabfe5338cc3de5ce4f45f6afa5f) | perf | selectively compress large entries in SQLite cache store | +| [0a137f98d](https://github.com/angular/angular-cli/commit/0a137f98d035f3dbd9a5daf1b6999246f73610c8) | perf | share sass directory and resolution caches across stylesheets | +| [f4dad31cc](https://github.com/angular/angular-cli/commit/f4dad31cce41bc3e8ed8767256d9e50a712c3a72) | perf | unify Oxc linking and optimization AST traversal passes | +| [2b85529e8](https://github.com/angular/angular-cli/commit/2b85529e8290c8c916539b5a0336b4efdb68578c) | perf | use iterative post-order AST traversal for OXC transforms | +| [ecbcd87b8](https://github.com/angular/angular-cli/commit/ecbcd87b8857225e4df3df7896b3236d63553f23) | perf | use native sass-embedded compiler daemon | +| [ce83ac0e8](https://github.com/angular/angular-cli/commit/ce83ac0e82981efd9408e07a35315e3cef9e1a36) | perf | use size-weighted task heuristics in i18n inliner | +| [d11a663e7](https://github.com/angular/angular-cli/commit/d11a663e77dad3dc9dbb6336e782f23b8a6f595c) | perf | verify cache metadata before reading file and eliminate sqlite read-locks | +| [66bd6bd1e](https://github.com/angular/angular-cli/commit/66bd6bd1eab41484ed5db38f5545e80de4090b34) | refactor | deprecate splitting option in unit-test builder | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [23e3d44a7](https://github.com/angular/angular-cli/commit/23e3d44a7f051cd3bb67700b8d8407f73b7aa7f3) | perf | pre-compile critical CSS plans at build time | + + + + + +# 22.1.9 (2026-09-23) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [b94dabdb7](https://github.com/angular/angular-cli/commit/b94dabdb744ff1fe72b79bfbda5f5c8749c58f71) | fix | update @types/node to a version vitest 5 accepts | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [5aaad0a13](https://github.com/angular/angular-cli/commit/5aaad0a13a3dc65b07347dde313ac9ae2aa013ac) | fix | name the unknown option in a schema validation error | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [bfec8bac4](https://github.com/angular/angular-cli/commit/bfec8bac4d6185efa768b4387ed9e8926979954f) | fix | support standard JavaScript MIME types and case insensitivity in auto-CSP | + + + + + +# 21.2.24 (2026-09-10) + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------- | +| [dbaadc2f6e](https://github.com/angular/angular-cli/commit/dbaadc2f6e73b611e292b3337066fd5b3b631397) | fix | update undici to 7.29.1 | + + + + + +# 20.3.37 (2026-09-10) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------- | +| [c37642011e](https://github.com/angular/angular-cli/commit/c37642011e6d8f7656a2bbc61feb79cae7d353c4) | fix | update undici to 7.29.1 | + + + + + +# 22.1.8 (2026-09-10) + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------- | +| [a6e30fa378](https://github.com/angular/angular-cli/commit/a6e30fa378ef5f03b26cd2fcafff6bff2c47da80) | fix | disable strictPort when port 0 is used in dev-server | +| [35a5d4aa49](https://github.com/angular/angular-cli/commit/35a5d4aa49011f83f58cdd30c3ae24a55bf0651c) | perf | avoid full JSON parsing when updating sourcemap ignore list | +| [48cc43cbf2](https://github.com/angular/angular-cli/commit/48cc43cbf204c887b344b773045b5460265eaa14) | perf | read rendered module length once per module in chunk optimizer ([#34045](https://github.com/angular/angular-cli/pull/34045)) | + + + + + +# 22.1.7 (2026-09-02) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [e9db85c0f](https://github.com/angular/angular-cli/commit/e9db85c0f5ef0aa0c67d2b8d90b872307af39822) | fix | isolate temporary package installations from parent Yarn workspace | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [1c9b1af61](https://github.com/angular/angular-cli/commit/1c9b1af61115764e0be55f5a1ed9a9967c191a74) | fix | add splitting option to unit-test builder | +| [4b861f458](https://github.com/angular/angular-cli/commit/4b861f458c8c789a5276f7b591bebd28e2080444) | fix | enable code splitting for vitest browser tests | +| [f1fd82381](https://github.com/angular/angular-cli/commit/f1fd823814b5e3620838beb423d5f33600c2e546) | fix | fail build and exclude routes when prerendering fails | +| [a84906fe3](https://github.com/angular/angular-cli/commit/a84906fe393e8c2dee978c02631af20e93627df1) | fix | keep dev-server Vite cache worktree-local | +| [fcf883ef1](https://github.com/angular/angular-cli/commit/fcf883ef166dc2e34d86007c0fcdfad1d0434c86) | fix | preserve binary values in the SQLite cache store | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [baf1ca189](https://github.com/angular/angular-cli/commit/baf1ca189dcf1ab0e55ac853c80f892dbd1864e4) | fix | decode pathname when constructing server asset path | +| [70748ca8e](https://github.com/angular/angular-cli/commit/70748ca8e76fe4b42798719410336119d943e755) | fix | ensure public directory containment in CommonEngine | + + + + + +# 21.2.23 (2026-09-02) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------- | +| [d3aaa48ef](https://github.com/angular/angular-cli/commit/d3aaa48ef35b80c3297ee3050cd239f84d051899) | fix | update less to 4.9.0 | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [645e41a47](https://github.com/angular/angular-cli/commit/645e41a47d21b7651837a4250de99af0509626e2) | fix | ensure public directory containment in CommonEngine | + + + + + +# 20.3.36 (2026-09-02) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------- | +| [b4059cdc7](https://github.com/angular/angular-cli/commit/b4059cdc7b7a65e68abfcf9fb53a6304790b1796) | fix | update less to 4.9.0 | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [c3e5982e4](https://github.com/angular/angular-cli/commit/c3e5982e49705f0f6a913cb94622b4c555d2d914) | fix | ensure public directory containment in CommonEngine | + + + + + +# 22.1.6 (2026-08-26) + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [3f132eaa97](https://github.com/angular/angular-cli/commit/3f132eaa97b95e16c58b8095c2566e3c78190446) | fix | ignore side-effect annotations in unit test builds | +| [5b1028936f](https://github.com/angular/angular-cli/commit/5b1028936f6e9582917f95ac37942dca0ee2c659) | perf | batch prerender routes and share in-memory server bundles | + +### @angular/ssr + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [76818cb811](https://github.com/angular/angular-cli/commit/76818cb811a8fe669575f988fec2e4528dca8c73) | fix | abort web request signal when node request is aborted | + + + + + +# 21.2.22 (2026-08-26) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------- | +| [b0047b04f5](https://github.com/angular/angular-cli/commit/b0047b04f56f5cb3fb8e4465ba0fad777497144a) | fix | update webpack-dev-server to 5.2.6 | + + + + + +# 20.3.35 (2026-08-26) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------- | +| [94b620acd7](https://github.com/angular/angular-cli/commit/94b620acd76e39c7d5cc8527dfc6fe9096855cbd) | fix | update webpack-dev-server to 5.2.6 | + + + + + +# 22.1.5 (2026-08-19) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [e672271f8](https://github.com/angular/angular-cli/commit/e672271f81e7b8b422b98c17fca5b67896d40844) | fix | enforce MCP roots in get_best_practices tool | +| [a14916cc4](https://github.com/angular/angular-cli/commit/a14916cc4b13412205d3e4a1c32d7e40be52f0e1) | fix | handle errors from isAllowedWorkspacePath in best-practices tool | +| [d6e1cddff](https://github.com/angular/angular-cli/commit/d6e1cddff92842f6889585ae7899efc028decc78) | fix | throw on out-of-roots workspace in best-practices tool | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [ba2b0e4c2](https://github.com/angular/angular-cli/commit/ba2b0e4c291842c699fdbeaaddec1531dbdd7952) | fix | transform fail() to expect.fail() in refactor-jasmine-vitest | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [cba72902d](https://github.com/angular/angular-cli/commit/cba72902d42022bd5a1bd17b23d65cacc0013e35) | fix | correct misleading error message for top-level await | +| [b4679998c](https://github.com/angular/angular-cli/commit/b4679998c205b6cce01d40fad24c4ee7954aa48d) | fix | disable code splitting for unit test builds | +| [ff1d3565e](https://github.com/angular/angular-cli/commit/ff1d3565e87749a8347c3be98cd4a7ebccb69fc0) | fix | preserve integrity and crossorigin in autoCsp loader | +| [1fc1fb05c](https://github.com/angular/angular-cli/commit/1fc1fb05cfe2c69ee7b9345b582d3c2177b923fd) | perf | traverse AST with iterative post-order walker in i18n inliner | + + + + + +# 22.1.4 (2026-08-13) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------- | +| [67a29899b](https://github.com/angular/angular-cli/commit/67a29899b07f760bac9582cbc6175966cce1c5d2) | fix | disable searching current directory for bare executable names on Windows | +| [1b0ba5c17](https://github.com/angular/angular-cli/commit/1b0ba5c17f0be2679b31bad1bee8b435315dac1a) | fix | serialize configuration as a single argv token in run_target strategies ([#33657](https://github.com/angular/angular-cli/pull/33657)) | +| [9440432d2](https://github.com/angular/angular-cli/commit/9440432d273c044965508d80e808c2fe123af0aa) | perf | avoid eager module loading during global bootstrap | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [d4a48e3ab](https://github.com/angular/angular-cli/commit/d4a48e3abe320e090eefe17cb70abdf8e7eafaed) | fix | generate CLAUDE.md for Claude Code instead of AGENTS.md | +| [d451f15b2](https://github.com/angular/angular-cli/commit/d451f15b2838a8282363dcb30161dd5dc858750f) | fix | import UrlSegment instead of subPath in guard generator | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [46fcb29d6](https://github.com/angular/angular-cli/commit/46fcb29d6c1ded962de95449d63716c13950f59a) | fix | count statically imported chunks in the initial total | +| [796b57684](https://github.com/angular/angular-cli/commit/796b576843191a4d5c74e4f9363ec0e46ae3a881) | fix | normalize setupFiles paths to POSIX for vitest runner | +| [c805e5cfb](https://github.com/angular/angular-cli/commit/c805e5cfb308d02a1944ef446d0a01af8a7ec6d2) | fix | prevent syntax corruption for Crockford-style enum IIFE | +| [cc6c17716](https://github.com/angular/angular-cli/commit/cc6c17716df5c411efca5e6f4c960f0565f45d90) | fix | prevent syntax corruption in oxc transform | +| [a142f6f83](https://github.com/angular/angular-cli/commit/a142f6f83d2f501b64f2404909ef9325bbfa70fb) | fix | return direct file contents for non-Angular TypeScript files | +| [956c6578d](https://github.com/angular/angular-cli/commit/956c6578d31d8482c04dc45723d7ab3b4fe355a5) | fix | return only lowest version per target engine | +| [689b5b116](https://github.com/angular/angular-cli/commit/689b5b116717140da98494e61ac5ea5ad7b68d73) | fix | set target for Rolldown dependency prebundling in Vite dev server | +| [70461f6ed](https://github.com/angular/angular-cli/commit/70461f6ed2e30aeaad2b93400ad00d9274665967) | perf | avoid encoding the inline source map before remapping | +| [22cce7563](https://github.com/angular/angular-cli/commit/22cce7563e82f17373b69e590d3e445767bd9264) | perf | batch last_accessed updates in sqlite cache store | +| [ab2ed18d5](https://github.com/angular/angular-cli/commit/ab2ed18d5739e8565403062ca74aa2d5cb3b95ec) | perf | hash the i18n inline cache key options once per locale | +| [34516ff73](https://github.com/angular/angular-cli/commit/34516ff736185e20ef8239b1fed8eda1f0193f62) | perf | optimize template string size calculation in server manifest | +| [712971c37](https://github.com/angular/angular-cli/commit/712971c370738beaebff18f6995859b1713ed8ae) | perf | share i18n translations with the inliner workers by reference | +| [78f8c5fef](https://github.com/angular/angular-cli/commit/78f8c5fef44dc6ff466b17c98fa1d01222cb1781) | perf | use Map for chunk asset size lookups in budget calculator | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [2331c1047](https://github.com/angular/angular-cli/commit/2331c10476830e7a53e0f2fa4a2f37dad58e2d5d) | fix | destroy platform when response stream is cancelled | + + + + + +# 21.2.21 (2026-08-12) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [ba5d16cac](https://github.com/angular/angular-cli/commit/ba5d16cacab4139897d1f0cf4161562b1bd00553) | fix | update dependency @modelcontextprotocol/sdk to v1.30.0 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [f7f60e69e](https://github.com/angular/angular-cli/commit/f7f60e69e77cda60378543a9ca9cc2c76c0bf34f) | fix | bump undici to 7.29.0 | + + + + + +# 20.3.34 (2026-08-12) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [3962517b9](https://github.com/angular/angular-cli/commit/3962517b95c76016ff7bcb53662704dff18c0552) | fix | update dependency @modelcontextprotocol/sdk to v1.30.0 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [f348efe8b](https://github.com/angular/angular-cli/commit/f348efe8b6d9c9d7014afb331eecda0a9478ba0a) | fix | bump undici to 7.29.0 | + + + + + +# 22.1.3 (2026-08-05) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| [f837b788c6](https://github.com/angular/angular-cli/commit/f837b788c6a23e7661db5455192f5a84d20e9b37) | fix | always install package during ng add to inspect manifest on disk for schematics ([#33718](https://github.com/angular/angular-cli/pull/33718)) | +| [72ae93638d](https://github.com/angular/angular-cli/commit/72ae93638dd83b3877773ac0d9f7e4c4e9056a33) | fix | correctly report available package updates during bare ng update | +| [a949e06568](https://github.com/angular/angular-cli/commit/a949e0656855786e4274159be09242ff9c4eb012) | fix | discover migrations from installed packages when omitted by registry metadata ([#33718](https://github.com/angular/angular-cli/pull/33718)) | +| [1d9122fd50](https://github.com/angular/angular-cli/commit/1d9122fd50f8a9b82199d44f9b0e075726279538) | fix | normalize Windows drive-letter casing for process.cwd | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------- | +| [a4860d505a](https://github.com/angular/angular-cli/commit/a4860d505aff0b43fc608cd0745ed187b1d407b5) | fix | prevent build process hang on unhandled error ([#33715](https://github.com/angular/angular-cli/pull/33715)) | +| [7686d66cab](https://github.com/angular/angular-cli/commit/7686d66cabcdfe90748ac405686d42674a19576f) | fix | retain watch files on error in load result cache | +| [25b7cf7504](https://github.com/angular/angular-cli/commit/25b7cf7504838d541abfba043d9ea3d616a23c04) | fix | support case-insensitive and alternative license file names | +| [4fd81402ce](https://github.com/angular/angular-cli/commit/4fd81402ce5f2f3db45a17ee0d116f12edf8d254) | perf | release build resources early in non-watch mode ([#33715](https://github.com/angular/angular-cli/pull/33715)) | + +### @angular/ssr + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [6af906cb94](https://github.com/angular/angular-cli/commit/6af906cb94bd0686b4c0431989e054711c74892a) | fix | settle writeResponseToNodeResponse when client disconnects | +| [917d3b2254](https://github.com/angular/angular-cli/commit/917d3b22549c19b91e03ceb0775a3a758142b9de) | perf | avoid buffering request body when sanitizing headers | + + + + + +# 21.2.20 (2026-08-05) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [972f6db7e9](https://github.com/angular/angular-cli/commit/972f6db7e9ccf3c4b0e895341a49b104a8b0ac9b) | fix | upgrade postcss to 8.5.23 | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [8de75ada50](https://github.com/angular/angular-cli/commit/8de75ada503d4182af7f0be957e58fe678a63b98) | fix | upgrade postcss to 8.5.23 | + + + + + +# 20.3.33 (2026-08-05) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [0b4008f17d](https://github.com/angular/angular-cli/commit/0b4008f17d76c821d9f075b86da80b826f085d23) | fix | upgrade postcss to 8.5.23 | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [fc861affcd](https://github.com/angular/angular-cli/commit/fc861affcd8866b71b81c8ba90f7a63890b6577c) | fix | upgrade postcss to 8.5.23 | + + + + + +# 22.1.2 (2026-07-30) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [57db27ea91](https://github.com/angular/angular-cli/commit/57db27ea910b102854267dfcd77e8fe60f23993c) | fix | add packages with built-in schematics to devDependencies only | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------ | +| [baed978885](https://github.com/angular/angular-cli/commit/baed978885585fceadc64f0151d7474054d57d6a) | fix | prevent IIFE wrapper interleaving for adjacent classes in minified files | + + + + + +# 22.1.1 (2026-07-30) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [ab971a8543](https://github.com/angular/angular-cli/commit/ab971a8543d80dfc93fdddf49598564f25196160) | fix | support npm 12 metadata array and error formats | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [db24220fb8](https://github.com/angular/angular-cli/commit/db24220fb8efb5c9416e375a468672ff69d0659d) | fix | rewrite paths from sandboxed execroots when running under Bazel | + + + + + +# 22.1.0 (2026-07-29) + +## Deprecations + +### @angular-devkit/core + +- `stringToFileBuffer` and `fileBufferToString` are deprecated. Use standard Web APIs (`TextEncoder` and `TextDecoder`) instead. + + Internal usages within the repository have been removed and replaced with standard Web APIs. The public API golden file for `@angular-devkit/core` has been updated to reflect the deprecations. + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [89d7f59cd](https://github.com/angular/angular-cli/commit/89d7f59cd7cc5d821ac0e81b1fee50e27877c976) | feat | update ai-config to include Angular MCP server config | +| [b345bb54d](https://github.com/angular/angular-cli/commit/b345bb54d3ec7c73f9db70400f823d90aeab8fa8) | fix | remove unused spec tsconfig outDir | + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [f59bcf529](https://github.com/angular/angular-cli/commit/f59bcf52917bc5e25b97c863f96127ec8d0f12f5) | fix | abort and guide manual mitigation when updating catalog packages | +| [48f90f5d5](https://github.com/angular/angular-cli/commit/48f90f5d5e5dd6369b2ccf044a722271c82dffd6) | fix | prevent Yarn registry environment variable override | +| [c963ecbec](https://github.com/angular/angular-cli/commit/c963ecbec241d42dfb419ae081ba22429e37f09e) | fix | remove algoliasearch dependency and support latest docs versions | +| [6859d25c3](https://github.com/angular/angular-cli/commit/6859d25c32b6c93cbbf2d4ad4143aa10b52b7588) | fix | resolve correct registry name when using npm alias syntax during update | +| [3a2589dac](https://github.com/angular/angular-cli/commit/3a2589dac684dfe26b2151b940bd40280d7c5d8e) | fix | respect client-side release age settings during update resolution | +| [5c3101bff](https://github.com/angular/angular-cli/commit/5c3101bff2e1b9d0b0e4668a183e8bcab8a1540e) | fix | revert package.json changes if installation tasks fail during update | +| [7932caaf9](https://github.com/angular/angular-cli/commit/7932caaf987c5692d6624f6af23e65ce3f6d27fd) | fix | robustly parse npm manifest from array | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------- | +| [fd336d365](https://github.com/angular/angular-cli/commit/fd336d365dbfe8f558db177a8da24790914a541b) | refactor | deprecate stringToFileBuffer and fileBufferToString | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------- | +| [34d558c3c](https://github.com/angular/angular-cli/commit/34d558c3c98e1b59a2c2808fc04bfbb63dfe8acf) | feat | add built-in SQLite cache store fallback | +| [0c18dc0f6](https://github.com/angular/angular-cli/commit/0c18dc0f6d4f36d7bbb6f35b37bfa688e8fcf7d2) | feat | emit debug ids for stable subresource integrity hashes | +| [51f69276f](https://github.com/angular/angular-cli/commit/51f69276fd2b2aab0e94901e79a51f6860ef8690) | feat | enable chunk optimization for server builds | +| [10dc30f9c](https://github.com/angular/angular-cli/commit/10dc30f9c680f46f65b5beb030f2a75c422a3e71) | feat | migrate advanced optimization Babel plugins to oxc-parser + magic-string | +| [917393a4c](https://github.com/angular/angular-cli/commit/917393a4cd85172574b155f727da5c1eae196fb1) | feat | migrate i18n inliner to oxc-parser + magic-string | +| [52ae7f862](https://github.com/angular/angular-cli/commit/52ae7f862a581cac97d5a9b33c9b71b830d95b19) | feat | share persistent build cache across git worktrees | +| [95510511d](https://github.com/angular/angular-cli/commit/95510511d963384c5a03298300246aa34a62328a) | fix | add bounded timeout to vitest executor disposal | +| [62b11e316](https://github.com/angular/angular-cli/commit/62b11e316e8248161877592acee5954b2a8c9c66) | fix | anchor debug ID comment matching and make injection idempotent | +| [6d9b360e5](https://github.com/angular/angular-cli/commit/6d9b360e5f2b7ab3d4ace4db35f674166630f658) | fix | prevent esbuild service hang on internal component stylesheet builds | +| [291caab61](https://github.com/angular/angular-cli/commit/291caab6158fa804f6221dd024a5b96e6181eaad) | fix | prevent stripping nested sourceMappingURL comments | +| [0e3ca3dfb](https://github.com/angular/angular-cli/commit/0e3ca3dfba8ef60217089d93a321660ebb6fb838) | fix | support hyphens and underscores when stripping chunk hash in chunk optimizer | +| [585d08af8](https://github.com/angular/angular-cli/commit/585d08af881a35653dc5e7b1ee7a75b42e154517) | perf | default chunk optimization to use Rolldown | +| [09d0a11a8](https://github.com/angular/angular-cli/commit/09d0a11a8b832f156aea100ad3497c5221fc0f50) | perf | enable fast-path AST printing with sourcemaps in AotCompilation | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [7ef9ed24d](https://github.com/angular/angular-cli/commit/7ef9ed24d3c111474ae21c06cb2b05a21804f586) | feat | support the standard Forwarded header | + + + + + +# 22.0.9 (2026-07-29) + +### @angular/cli + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [9ee8f9b669](https://github.com/angular/angular-cli/commit/9ee8f9b6692b8efa2d0cabd502cd5a598f436c8a) | fix | declare devserver_start and devserver_stop as isReadOnly:false | + +### @angular/build + +| Commit | Type | Description | +| ---------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [d0c776d461](https://github.com/angular/angular-cli/commit/d0c776d4610e2327c29900b9b3d679e9c0f28061) | fix | safeguard Karma builder stream controller against closed state | + + + + + +# 22.0.8 (2026-07-23) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [41c356256](https://github.com/angular/angular-cli/commit/41c356256525469a41ea72896b1ddff91a4e6e55) | fix | batch Prettier invocations during migration formatting | +| [bc87c85c3](https://github.com/angular/angular-cli/commit/bc87c85c3c660e969cf77a493dc4ee58fd831214) | fix | update MCP devserver tool names to conform to spec regex | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [5724bf632](https://github.com/angular/angular-cli/commit/5724bf632eba59eccdd941beae8aafad35ce003e) | fix | canonicalize drive letter casing for workspace root on Windows | +| [b8bdbaa1e](https://github.com/angular/angular-cli/commit/b8bdbaa1ea1c58f061580bd58c015078f57c1530) | fix | ensure import map integrity keys are valid URL-like specifiers | +| [212a7bb21](https://github.com/angular/angular-cli/commit/212a7bb21eca131f32fd7b4e7b5a9ab87f2bfded) | fix | favor istanbul coverage provider when browser testing is enabled | +| [bc760b005](https://github.com/angular/angular-cli/commit/bc760b005e0dae10a2dfd31a7f0f772482305398) | fix | preserve custom config options in runnerConfig for vitest | +| [19cda7415](https://github.com/angular/angular-cli/commit/19cda7415693f36e911a736a32576110905b6600) | fix | remap metafile paths when workspace root is a symlink or junction | +| [fdcc98c79](https://github.com/angular/angular-cli/commit/fdcc98c7912f45431268fdf66c8b132bd119bc27) | perf | skip semantic affected-file walk when type checking is disabled | + + + + + +# 22.0.7 (2026-07-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [e85c10ccb](https://github.com/angular/angular-cli/commit/e85c10ccb551eeaf9ac7e819ee6eb095317b5032) | fix | copy packageManager field and yarn config for temp installs | +| [b87cafec0](https://github.com/angular/angular-cli/commit/b87cafec04b24c2f4337ecfa00388197ab5003f0) | fix | support resolving subproject dependencies in pnpm workspaces | + + + + + +# 21.2.19 (2026-07-09) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [df5429871](https://github.com/angular/angular-cli/commit/df54298712bb84ec079787e4ca6c0164f6358cf1) | fix | update http-proxy-middleware to 3.0.7 | + + + + + +# 20.3.32 (2026-07-09) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [d4e07cb3e](https://github.com/angular/angular-cli/commit/d4e07cb3e3f871822f9bf37fdea35814ec1cdb80) | fix | update http-proxy-middleware to 3.0.7 | + + + + + +# 22.0.6 (2026-07-08) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------ | +| [df49966c6](https://github.com/angular/angular-cli/commit/df49966c6b8ec0c10b58fee18981fc2992ac071a) | fix | fallback to build target preserveSymlinks option in karma runner | +| [9906add5b](https://github.com/angular/angular-cli/commit/9906add5b722cca7f61488b367dd73545c6e13bc) | fix | prevent externalizing builder-injected i18n locale data | +| [070155f6e](https://github.com/angular/angular-cli/commit/070155f6ead185d4b478d9034662d65b8bb86a23) | fix | strip all vite id prefixes from minified code with external dependencies | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [c9aa354b1](https://github.com/angular/angular-cli/commit/c9aa354b1de0c946ddc994191613bb809a799f81) | fix | replace all route parameters when resolving relative redirects | + + + + + +# 22.0.5 (2026-07-01) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [f099e641c](https://github.com/angular/angular-cli/commit/f099e641c96a5932d81535e712af7044269b4788) | fix | gracefully handle package manager errors in command handler | +| [cf033ec48](https://github.com/angular/angular-cli/commit/cf033ec48e2371655fbf6bcdb588c05aff2e6662) | fix | respect release age policy in update bootstrapping logic | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------- | +| [862a38069](https://github.com/angular/angular-cli/commit/862a380697db60cee8cf83e8fe20641db2a6aae1) | fix | remove unused app tsconfig outDir | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [f75085f4b](https://github.com/angular/angular-cli/commit/f75085f4b58ed61979a2901a4ef0353e56f22136) | fix | prevent OS command injection in ssr-dev-server builder | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [dfee1e7b1](https://github.com/angular/angular-cli/commit/dfee1e7b142d767c86eecb7791ed72bce65866c5) | fix | auto-inject localize/init in library unit tests | +| [aba718403](https://github.com/angular/angular-cli/commit/aba718403df7a96310570c1449674a49254cd4af) | fix | inherit preserveSymlinks option from build target in unit-test builder | +| [7c0d9f03c](https://github.com/angular/angular-cli/commit/7c0d9f03c2ae82a90a48e51b2aee731a36dc2beb) | fix | load zone.js dynamically for library unit tests | +| [4288e4452](https://github.com/angular/angular-cli/commit/4288e44525e855f29f026276f518968b1b2697db) | fix | scope createRequire module resolution using paths to prevent parent paths | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [5f774f891](https://github.com/angular/angular-cli/commit/5f774f891a086bf0a2ab6f86c24485425e99f16d) | fix | preserve response headers during redirect | + + + + + +# 21.2.18 (2026-07-01) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [61a810ab6](https://github.com/angular/angular-cli/commit/61a810ab615d1a26fc69f9e48be7cc6555a9c430) | fix | respect release age policy in update bootstrapping logic | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [a2b6116ed](https://github.com/angular/angular-cli/commit/a2b6116eddc7a71e4539817da623221ca552a35f) | fix | bump undici to 7.28.0 | +| [2510ee3c9](https://github.com/angular/angular-cli/commit/2510ee3c9aefdab5428938e78b9cfcea0ada1c45) | fix | bump vite to 7.3.6 | + + + + + +# 20.3.31 (2026-07-01) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [4ea787cd0](https://github.com/angular/angular-cli/commit/4ea787cd0dfc7226896643a6f8ccd99846c85c2c) | fix | bump undici to 7.28.0 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------ | +| [df5a2cc2b](https://github.com/angular/angular-cli/commit/df5a2cc2bcf8d65d834164984ee5456718dfb75e) | fix | bump vite to 7.3.6 | + + + + + +# 22.0.4 (2026-06-24) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [46185ceac](https://github.com/angular/angular-cli/commit/46185ceacc86cc04819b991621b7bb4e0d865b5a) | fix | aggregate parallel worker performance timings on the main thread | +| [5d7e29c41](https://github.com/angular/angular-cli/commit/5d7e29c41ed3fe0abe77dcdc894d5b7875293cb5) | perf | dispose builder result context early in non-watch mode | + + + + + +# 21.2.17 (2026-06-24) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------- | +| [9c366e8be](https://github.com/angular/angular-cli/commit/9c366e8be42495d1bf90033788b3beae77e5fc28) | fix | bump @babel/core to 7.29.7 | +| [6a06ce9e9](https://github.com/angular/angular-cli/commit/6a06ce9e9847b4ddd66b94b9ea4c6fbbfc28d066) | fix | bump esbuild to 0.28.1 | +| [637b84877](https://github.com/angular/angular-cli/commit/637b84877334de5acc6fcbe1ba4d62c7a6da420b) | fix | bump piscina to 5.2.0 | +| [e86d58a12](https://github.com/angular/angular-cli/commit/e86d58a128725eee9dd63dcd2ccd35b8ee4706f3) | fix | bump vite to 7.3.5 | + + + + + +# 20.3.30 (2026-06-24) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------- | +| [36adca99c](https://github.com/angular/angular-cli/commit/36adca99ce72d59e5909709f53e239b1e79d703e) | fix | bump @babel/core to 7.29.7 | +| [7f236cfea](https://github.com/angular/angular-cli/commit/7f236cfea9023a77c6ccaf1648f388678017ce1e) | fix | bump esbuild to 0.28.1 | +| [94fb3c122](https://github.com/angular/angular-cli/commit/94fb3c122a14a3a8540a24e22f2f8ff87d130cb5) | fix | bump piscina to 5.2.0 | +| [1f283a41c](https://github.com/angular/angular-cli/commit/1f283a41c921b194c67c5bca05d4b5cff1d91857) | fix | bump vite to 7.3.5 | + + + + + +# 22.0.3 (2026-06-18) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [0eddea898](https://github.com/angular/angular-cli/commit/0eddea898d3bf4da8c9c5c27ec2ee79916e8be87) | fix | remove default workspace vscode mcp.json configuration | + + + + + +# 21.2.16 (2026-06-17) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------- | +| [77c9047ac](https://github.com/angular/angular-cli/commit/77c9047acdf4b69ec6dd399af2dff7278e8014c7) | fix | update pacote to 21.5.1 | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [d052e97da](https://github.com/angular/angular-cli/commit/d052e97dafe2e85fbc072fd11025c803ebb80ef1) | fix | prioritize options over environment variables in AngularNodeAppEngine | + + + + + +# 20.3.29 (2026-06-17) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------- | +| [5f7c0328c](https://github.com/angular/angular-cli/commit/5f7c0328c3fe1a003ade44861d9aac98f485f014) | fix | update pacote to 21.5.1 | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [a75d78e68](https://github.com/angular/angular-cli/commit/a75d78e681b10436971d8071450b8cfd93b2d72a) | fix | prioritize options over environment variables in AngularNodeAppEngine | + + + + + +# 22.0.2 (2026-06-17) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [136fc2714](https://github.com/angular/angular-cli/commit/136fc27149af74263ef519007f0a74f9f85c5b4d) | fix | support registry metadata fetching under bun package manager | +| [2653dd5c7](https://github.com/angular/angular-cli/commit/2653dd5c7d47149b61bfe6edf4ab1281347e89dd) | perf | implement semaphore backpressure throttling in PackageManager | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [0b4a48add](https://github.com/angular/angular-cli/commit/0b4a48add9d7218e698af0db974bd597bed8a121) | perf | implement semaphore backpressure throttling in JavaScriptTransformer | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [d996a27e9](https://github.com/angular/angular-cli/commit/d996a27e9744b473a7db56f81871731b8bdce585) | fix | avoid caching non-SSG page lookups | +| [285a34e42](https://github.com/angular/angular-cli/commit/285a34e42f1cdc512468de0041b232c7190e7d7e) | fix | correct grammar in console warning for redirected location headers | +| [c8088a536](https://github.com/angular/angular-cli/commit/c8088a536c2c747a273e37be682643e1b35e2f75) | fix | prioritize options over environment variables in AngularNodeAppEngine | + + + + + +# 20.3.28 (2026-06-11) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------- | +| [e3d564667](https://github.com/angular/angular-cli/commit/e3d5646679215d9b73a72e04d87aa7848f2c01d2) | fix | fallback to deprecated versions when resolving ranges if no non-deprecated version is found | +| [f12e17025](https://github.com/angular/angular-cli/commit/f12e17025a262f9432afd58971c47aec7dbfab25) | fix | remove forceAuth and unscoped credential parsing | + + + + + +# 21.2.15 (2026-06-11) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [42ac0ed0f](https://github.com/angular/angular-cli/commit/42ac0ed0ff8b98862b9df1cef048f463ddc2cd85) | fix | remove forceAuth and unscoped credential parsing | +| [c7a7f1955](https://github.com/angular/angular-cli/commit/c7a7f1955619717ca775f730d67e3311047537f8) | fix | support registry metadata fetching under bun package manager | + + + + + +# 22.0.1 (2026-06-10) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------- | +| [b54e9a549](https://github.com/angular/angular-cli/commit/b54e9a549d30871f6017b1db4cf7a4ab5f3e02db) | fix | do not sort migrations of the same version alphabetically | +| [d33311612](https://github.com/angular/angular-cli/commit/d333116123c7d3d5e87713b7baac048b78f28517) | fix | fallback to local package.json for schematic detection on first run | +| [918102a93](https://github.com/angular/angular-cli/commit/918102a9373085394c41f10d9f5df3e3c17b263f) | fix | isolate temporary package installation from parent pnpm workspace | +| [b048b5f4a](https://github.com/angular/angular-cli/commit/b048b5f4a83d7b20095d79654b849808e7d58fdb) | fix | remove forceAuth and unscoped credential parsing | +| [277934035](https://github.com/angular/angular-cli/commit/277934035138c5af803e8daeebc2313f0a4cb5b3) | fix | validate registry option is a valid URL in ng add | +| [4510dae02](https://github.com/angular/angular-cli/commit/4510dae021ab25bb852eeed6415dbd52cfabfce5) | perf | optimize update schematic registry query counts by fetching package metadata lazily | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [c80012294](https://github.com/angular/angular-cli/commit/c8001229453211b37cd7bb12ed26a2deb9257fd5) | fix | fix browserMode option mapping in refactor-jasmine-vitest | +| [a9b6bd904](https://github.com/angular/angular-cli/commit/a9b6bd9042d6b859c384a6fc782541fca30dfb68) | fix | safely comment out multiline statements in refactor-jasmine-vitest | +| [12199df00](https://github.com/angular/angular-cli/commit/12199df00f2e3e8436ada13e04799e5825eb3f7b) | fix | use null objects and callbacks in karma-to-vitest migration | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [89d1be979](https://github.com/angular/angular-cli/commit/89d1be979f388d85e9c428bbf1df4e7fb4036dce) | fix | allow disabling Vitest isolation from builder | +| [d45b84be9](https://github.com/angular/angular-cli/commit/d45b84be9a607e49b391cb216cb6de7eca274931) | fix | exclude JSON imports from Vite dependency optimization | +| [e3cab4ddd](https://github.com/angular/angular-cli/commit/e3cab4dddade2538125e8a2f345f42c95e26aeae) | fix | prevent concurrent stylesheet bundling esbuild context leaks | +| [bd413b0eb](https://github.com/angular/angular-cli/commit/bd413b0eb156184ea432cbb7d4e6d7f6f70813ab) | fix | restrict application builder output paths to output directory | + + + + + +# 22.0.0 (2026-06-03) + +## Breaking Changes + +### + +- Node.js v20 is no longer supported. The minimum supported Node.js versions are now v22.22.0 and v24.13.1. +- The `@angular-devkit/architect-cli` package is no longer available. The `architect` CLI tool has been moved to the `@angular-devkit/architect` package. +- The experimental `@angular-devkit/build-angular:jest` and `@angular-devkit/build-angular:web-test-runner` builders have been removed. + +### @angular/build + +- The `@angular/build:dev-server (ng serve)` now assigns the highest priority to the `PORT` environment variable. This value will override any port configurations specified in `angular.json` or via the `--port` command-line flag. This includes the default port 4200. +- `istanbul-lib-instrument` is now an optional peer dependency. + Projects using karma with code coverage enabled will need to ensure that istanbul-lib-instrument is installed. Note: `ng update` will automatically add this dependency during the update process. + +### @angular/ssr + +- The server no longer falls back to Client-Side Rendering (CSR) when a request fails host validation. Requests with unrecognized 'Host' headers will now return a 400 Bad Request status code. Users must ensure all valid hosts are correctly configured in the 'allowedHosts' option. + +## Deprecations + +### @angular-devkit/build-angular + +- Webpack builders in build-angular are deprecated. Use @angular/build builders instead. + +### @angular-devkit/build-webpack + +- Webpack builders in build-webpack are deprecated. Use @angular/build builders instead. + +### @angular/ssr + +- CommonEngine APIs are deprecated in favor of AngularNodeAppEngine or AngularAppEngine. + +### @ngtools/webpack + +- @ngtools/webpack loader and plugin are deprecated. Use @angular/build instead. + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [58c0978f6](https://github.com/angular/angular-cli/commit/58c0978f658ee5fa7232abd8e2eb7f146e4eb6bb) | feat | add support for Node.js 26.0.0 | +| [a5c7c0b5f](https://github.com/angular/angular-cli/commit/a5c7c0b5fda4ae0c00351ba34e5f39dab2c6baee) | fix | reflect new minimum supported Node version in ng.js | +| [a5e1e48db](https://github.com/angular/angular-cli/commit/a5e1e48db759e9ffcaa89f04504f5f93a1afdda4) | fix | update odd-numbered Node.js version warning condition for future releases | +| [93c3eb8fb](https://github.com/angular/angular-cli/commit/93c3eb8fb2a0d531f18779152b0a62e9b73dbb23) | fix | update zoneless migration tool to handle `ChangeDetectionStrategy.Eager` | +| [a39a33128](https://github.com/angular/angular-cli/commit/a39a33128fb56e9c65ea89e06c4f127252d3b220) | perf | cache root manifest and resolve restricted package exports in ng add | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [be60a63b7](https://github.com/angular/angular-cli/commit/be60a63b7b8fdea26bfd3329ce54d321334db2d2) | feat | add migrate-karma-to-vitest update migration | +| [43505066e](https://github.com/angular/angular-cli/commit/43505066e2350ca875051bb0f9991da8003c9247) | feat | add migration to add istanbul-lib-instrument | +| [b2f7a038b](https://github.com/angular/angular-cli/commit/b2f7a038b4a321e4e1b0b340cd09425f948c77ad) | feat | conditionally install istanbul coverage provider for Vitest migration | +| [d227e6985](https://github.com/angular/angular-cli/commit/d227e6985ef5540e0eea2571577ee2b9be0d3c64) | feat | migrate fake async to Vitest fake timers | +| [d2aa9ede5](https://github.com/angular/angular-cli/commit/d2aa9ede55a3e16b61ce6ae60dba6c8ea8954358) | feat | migrate fakeAsync's flush behavior when used in beforeEach | +| [f98cc82eb](https://github.com/angular/angular-cli/commit/f98cc82eb0f46986e61b4f94b57dcd36e4eaf215) | feat | rely on strict template default in generated workspaces | +| [c9f408153](https://github.com/angular/angular-cli/commit/c9f4081533f6f114846b88a152a9d5dc7363d680) | feat | set up fake timers in beforeEach instead of beforeAll | +| [de630c2fc](https://github.com/angular/angular-cli/commit/de630c2fcee22622942478c98117d8f473d8c894) | feat | stabilize refactor-jasmine-vitest schematic | +| [8d0805dd1](https://github.com/angular/angular-cli/commit/8d0805dd1750cb16af620811dc01b40e46ad030e) | feat | update TSConfig globals during karma to vitest migration | +| [470e1f937](https://github.com/angular/angular-cli/commit/470e1f937492e73971dea9c39af83368caf74e42) | fix | add istanbul-lib-instrument to application/library generator dependencies | +| [dc1238e5a](https://github.com/angular/angular-cli/commit/dc1238e5a4c9ab5902735e3d74bc7c5cc57553bd) | fix | add trusted-proxy-headers migration | +| [6572a6944](https://github.com/angular/angular-cli/commit/6572a69443356ff0022e6ce162915125fee0e3bb) | fix | default components to OnPush change detection | +| [aed407db8](https://github.com/angular/angular-cli/commit/aed407db8be6bc7591fb82f10c79586cbd072a8a) | fix | defer karma config deletion in Karma to Vitest migration | +| [4fbc60891](https://github.com/angular/angular-cli/commit/4fbc6089174142623fbd28cc68cede181074fab5) | fix | preserve Jasmine stub-by-default semantics for bare spies | +| [b3d838dfd](https://github.com/angular/angular-cli/commit/b3d838dfdb2adc3bd035b495f7f9457d742d73a4) | fix | replace deprecated `ChangeDetectionStrategy.Default` with `Eager` | +| [a7ac8e5f0](https://github.com/angular/angular-cli/commit/a7ac8e5f0a268994a8fcfebbf56f76e994b6207d) | fix | support spy call arguments migration in refactor-jasmine-vitest | +| [7fb59eaa6](https://github.com/angular/angular-cli/commit/7fb59eaa65a8d7e880b6f44d715b2aeaff9301ca) | fix | use service decorator in ng generate | + +### + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------- | +| [d0e9e8163](https://github.com/angular/angular-cli/commit/d0e9e81639be33c453f1ef66c7c1c8ea4fe543c2) | build | update minimum supported Node.js versions | +| [1f21e89d9](https://github.com/angular/angular-cli/commit/1f21e89d99c191642627df6842402644a1bf26ee) | refactor | remove `@angular-devkit/architect-cli` package | +| [b4885b851](https://github.com/angular/angular-cli/commit/b4885b851226709ae4146070122806e14a3d5eb9) | refactor | remove experimental Jest and Web Test Runner builders | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | -------------------------- | +| [b7940dbcb](https://github.com/angular/angular-cli/commit/b7940dbcb40291be4de5b31e8a8001165459a7d4) | refactor | deprecate Webpack builders | + +### @angular-devkit/build-webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------- | +| [3d5daa45e](https://github.com/angular/angular-cli/commit/3d5daa45e3ade025c1bc0df35d2766563ccf7c03) | refactor | deprecate webpack and webpack-dev-server builders | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [e9aa7967b](https://github.com/angular/angular-cli/commit/e9aa7967ba391e429ec1c04473efb4a2d38a62bf) | feat | add isolate option to unit-test builder | +| [fe720cab6](https://github.com/angular/angular-cli/commit/fe720cab64bbc8bcc2db583188e32ad938e63a23) | feat | add process.env.PORT support to the dev server | +| [0781f7498](https://github.com/angular/angular-cli/commit/0781f74986e773d143b9bdf5d3f0747de0df6b0a) | feat | add quiet option to suppress build noise in unit tests | +| [cd2ad3c19](https://github.com/angular/angular-cli/commit/cd2ad3c1958d9adadc214045b5e59c0c8f8b4499) | feat | enable chunk optimization by default with heuristics | +| [3007f46b7](https://github.com/angular/angular-cli/commit/3007f46b7e077227b17d8bb1090edd2f8ff19ae3) | feat | move istanbul-lib-instrument to optional peer dependency | +| [af2c7e944](https://github.com/angular/angular-cli/commit/af2c7e9444fba81d3b1fd2d37dc4412f8305b5ed) | feat | rename `experimentalPlatform` to `platform` in application builder | +| [58c7c7a9d](https://github.com/angular/angular-cli/commit/58c7c7a9d80fc6af5cf8b82a6d87f1d3cf3808c6) | feat | subresource integrity validation for dynamically loaded modules | +| [73233dc5f](https://github.com/angular/angular-cli/commit/73233dc5f41b570f5ac095b255a46de6cbfb54b7) | feat | support Istanbul coverage in Vitest runner | +| [414320d02](https://github.com/angular/angular-cli/commit/414320d02a090b7b36720051569cba7563bf9ac6) | feat | support runtime Zone.js detection in Vitest unit test runner | +| [b85ec6798](https://github.com/angular/angular-cli/commit/b85ec6798b5c74f778a69a657729e446a2156261) | fix | allow configuring Access-Control-Allow-Origin via headers option | +| [edfa782d5](https://github.com/angular/angular-cli/commit/edfa782d52fd971aebead8b96b6ca470a3f5123e) | fix | use dynamic TestComponentRenderer for Vitest | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------- | +| [5adc92541](https://github.com/angular/angular-cli/commit/5adc92541433be23fc2246db5a199cf5c0dc9e67) | fix | enforce explicit opt-in for proxy headers | +| [f85343925](https://github.com/angular/angular-cli/commit/f8534392552f4896ee9449939cdc705010331e3d) | fix | support all X-Forwarded-\* headers when trustProxyHeaders is true | +| [8471ba634](https://github.com/angular/angular-cli/commit/8471ba63433d87ba3e59af622ed9cfa89b732cd9) | fix | support server-side rendering configuration options | +| [50b16a65b](https://github.com/angular/angular-cli/commit/50b16a65b1be1f9c2ec11d578240a8884518d517) | refactor | deprecate CommonEngine APIs | +| [27cd35561](https://github.com/angular/angular-cli/commit/27cd355619aad140dfc221c6bd161f4a981e0f3b) | refactor | remove CSR fallback for invalid hosts | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------- | +| [547ca515b](https://github.com/angular/angular-cli/commit/547ca515b707c283489a3f088d86fc84807d830d) | refactor | deprecate @ngtools/webpack loader and plugin | + + + + + +# 21.2.14 (2026-06-03) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------- | +| [aed448748](https://github.com/angular/angular-cli/commit/aed448748451b6b87c20e7bd9b3b1f822067f681) | fix | expand package groups for newly added peer dependencies in update schematic | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [d46c082fb](https://github.com/angular/angular-cli/commit/d46c082fb994138327eb36882373f63daeec8bc1) | fix | prevent esbuild service child process leakage | + + + + + +# 20.3.27 (2026-06-02) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------- | +| [07c6c96ba](https://github.com/angular/angular-cli/commit/07c6c96baf2901f44322beb70b77c32dc0824e45) | fix | add support for configuring trusted proxy headers via environment variable | + + + + + +# 19.2.27 (2026-06-02) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------- | +| [2c0dfc2ac](https://github.com/angular/angular-cli/commit/2c0dfc2ac8ce2f08741d25f687b4c605477978d6) | fix | add support for configuring trusted proxy headers via environment variable | + + + + + +# 21.2.13 (2026-05-27) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [3c6d26a31](https://github.com/angular/angular-cli/commit/3c6d26a316cd6aea455c19b249dc6852d84a698e) | fix | remove unconditional CORS wildcard from webpack dev-server | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [2b3e95517](https://github.com/angular/angular-cli/commit/2b3e95517358f8ef3482d5319d970f4774e45ad0) | fix | assert that asset input paths are within workspace root | + + + + + +# 21.2.12 (2026-05-20) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [cbad57579](https://github.com/angular/angular-cli/commit/cbad57579adb5de7887985afbb2bf1f40adf3cb2) | fix | ignore virtual esbuild paths with (disabled): | + + + + + +# 21.2.11 (2026-05-13) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [bbd63b7a5](https://github.com/angular/angular-cli/commit/bbd63b7a5a1049bc56b9ddf6edf6563a1f2d9ace) | fix | robustly parse npm manifest from array | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [eafe1a719](https://github.com/angular/angular-cli/commit/eafe1a719fd3fecd5263e0a8371200b4b1ff4bb9) | fix | allow all hosts in common engine rendering options to prevent validation errors | +| [7a116a80d](https://github.com/angular/angular-cli/commit/7a116a80d7e6db341fd003737285d1a9db10ba6c) | fix | remove stateful flag from URL_PARAMETER_REGEXP | + + + + + +# 20.3.26 (2026-05-13) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [7cc1871ee](https://github.com/angular/angular-cli/commit/7cc1871ee50d123853ddf6bd89857b354d647462) | fix | allow all hosts in common engine rendering options to prevent validation errors | + + + + + +# 19.2.26 (2026-05-13) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [842fee029](https://github.com/angular/angular-cli/commit/842fee0291b787b63fdabcaaac5680b05d395075) | fix | allow all hosts in common engine rendering options to prevent validation errors | + + + + + +# 21.2.10 (2026-05-06) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [bb8611913](https://github.com/angular/angular-cli/commit/bb861191328fc2d25bd5ee99b0c8edc5e49d3a7d) | fix | restrict MCP workspace access to allowed client roots during resolution | + + + + + +# 19.2.25 (2026-04-30) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [49ae0ad2d](https://github.com/angular/angular-cli/commit/49ae0ad2d452ab7d7510297919125fe00081fa49) | fix | upgrade postcss to 8.5.12 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------- | +| [2d53feca5](https://github.com/angular/angular-cli/commit/2d53feca58730def7e2fb79f9cabfd1c32b9351d) | fix | update esbuild to `0.28.0` | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------- | +| [02ce8bf26](https://github.com/angular/angular-cli/commit/02ce8bf2695e71c1684e231151b4ab5ccba94869) | fix | introduce trustProxyHeaders option to safely validate and sanitize proxy headers | + + + + + +# 21.2.9 (2026-04-29) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [233deef01](https://github.com/angular/angular-cli/commit/233deef01288c6aa39a048d6bd66a1f09595dc15) | fix | fix broken img ref in ai-tutor | +| [7cea9885c](https://github.com/angular/angular-cli/commit/7cea9885c64a747b391b74e6434cdf005c843766) | fix | introduce initial package manager workspace awareness | +| [5b1a5b743](https://github.com/angular/angular-cli/commit/5b1a5b7434323eb383df1f53c389fe9dc948a785) | fix | remove standalone true ref in ai tutor | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------- | +| [e7abeb5c7](https://github.com/angular/angular-cli/commit/e7abeb5c74024daf125070c9b4f7f8d2426bab66) | fix | add missing imports for focus and skip APIs in refactor-jasmine-vitest | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------- | +| [94023f62c](https://github.com/angular/angular-cli/commit/94023f62c5b51a669ff3187cc062057f82c79c52) | fix | introduce trustProxyHeaders option to safely validate and sanitize proxy headers | +| [5ffe5c309](https://github.com/angular/angular-cli/commit/5ffe5c309a92e7fd42f1059171924c5942f35c36) | fix | add support for configuring trusted proxy headers via environment variable | +| [930ada9b7](https://github.com/angular/angular-cli/commit/930ada9b73c5172d57830ce42a5a336ae9483a54) | fix | decode route segments when building and matching route tree | +| [0dc8a440c](https://github.com/angular/angular-cli/commit/0dc8a440ca9e4242edb69ba5a8147fef1dddc4f0) | fix | use router to normalize URLs for comparison | + + + + + +# 20.3.25 (2026-04-29) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [5e01ef40e](https://github.com/angular/angular-cli/commit/5e01ef40eb87deda79d18654fc696b64d18bf889) | fix | upgrade postcss to 8.5.12 | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------- | +| [6686848d9](https://github.com/angular/angular-cli/commit/6686848d946ca157f9b92b84db377e912266395e) | fix | introduce trustProxyHeaders option to safely validate and sanitize proxy headers | + + + + + +# 20.3.24 (2026-04-15) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------- | +| [10c09c77b](https://github.com/angular/angular-cli/commit/10c09c77b75602293377b962b2a8397a2819036c) | fix | update esbuild to `0.28.0` | + + + + + +# 21.2.7 (2026-04-08) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [971041aa1](https://github.com/angular/angular-cli/commit/971041aa11e4546772e4677b77ceec2ad1374496) | fix | handle missing package manager during analytics initialization | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [365cce81d](https://github.com/angular/angular-cli/commit/365cce81dde91463d4f0049a69feaed018902585) | fix | preserve coverage ignore comments in development | +| [9f74660c3](https://github.com/angular/angular-cli/commit/9f74660c3688bb1f0d6a7608a136a30dbfe4d7e9) | fix | show clear error when styleUrl points to a TypeScript file | + + + + + +# 20.3.23 (2026-04-08) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------- | +| [ccab02ba0](https://github.com/angular/angular-cli/commit/ccab02ba0413f25464a6e4cb5871716b221013b7) | fix | update vite to `7.3.2` | + + + + + +# 19.2.24 (2026-04-08) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------- | +| [f4595d599](https://github.com/angular/angular-cli/commit/f4595d599001713ae978865d7f84e33e5a9a077a) | fix | update vite to `6.4.2` | + + + + + +# 21.2.6 (2026-04-01) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [ea14f28cc](https://github.com/angular/angular-cli/commit/ea14f28ccfc6e5534eaef516bf1bfbe21582da04) | fix | fix sourceRoot resolution for MCP projects tool | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [9136eb376](https://github.com/angular/angular-cli/commit/9136eb37630d6315891b3c881cd0ba4037c3254c) | fix | ensure transitive SCSS partial errors are tracked in watch mode | +| [8186faa11](https://github.com/angular/angular-cli/commit/8186faa117803ffb6ac8e2c4cd6ab7873502308d) | fix | ensure Vitest mock patching is executed only once | +| [107d1a9e2](https://github.com/angular/angular-cli/commit/107d1a9e26fc59c7878254e563758818866f0f6e) | fix | preserve error stack traces during prerendering | +| [b7f457253](https://github.com/angular/angular-cli/commit/b7f4572533675729e87532bdc23509feb2f3a28d) | fix | scope CHROME_BIN executable path to individual playwright instances | + + + + + +# 21.2.5 (2026-03-27) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [cadf9b201](https://github.com/angular/angular-cli/commit/cadf9b201bd1055a6e3cc016eb01e0196b028080) | feat | support custom port in MCP devserver start tool | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [bbc255419](https://github.com/angular/angular-cli/commit/bbc255419b346e5152391b47f310922f86e9e383) | fix | allow underscores in host validation | +| [b1fe66a7f](https://github.com/angular/angular-cli/commit/b1fe66a7f8650ce021b4070394bc31848fc64ca5) | fix | patch Headers.forEach in cloneRequestAndPatchHeaders | + + + + + +# 20.3.22 (2026-03-27) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [5978eeeff](https://github.com/angular/angular-cli/commit/5978eeeff63cd62f1515d949eaad0b5e6f7c44cd) | fix | update picomatch to 4.0.4 | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [6e9b92612](https://github.com/angular/angular-cli/commit/6e9b926129a9dd79f01d47b7446411b8963ffb62) | fix | update picomatch to 4.0.4 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [6f209c26d](https://github.com/angular/angular-cli/commit/6f209c26dc5a454acd1cd76f25240c26978fa827) | fix | update picomatch to 4.0.4 | + + + + + +# 19.2.23 (2026-03-27) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [67cfbe32f](https://github.com/angular/angular-cli/commit/67cfbe32ff013d7c9763253c8ccd383d8ee2416c) | fix | update picomatch to 4.0.4 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [771b979e7](https://github.com/angular/angular-cli/commit/771b979e78af079242bea607470b3ad3b6162659) | fix | update picomatch to 4.0.4 | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [de2da4874](https://github.com/angular/angular-cli/commit/de2da4874026bb47d08de828b6b4b44fd7d9a3c4) | fix | update picomatch to 4.0.4 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [27a9ce4a7](https://github.com/angular/angular-cli/commit/27a9ce4a7446db0533e4a7ef64301eeccc5e21e1) | fix | update picomatch to 4.0.4 | + + + + + +# 21.2.4 (2026-03-26) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [a7787d092](https://github.com/angular/angular-cli/commit/a7787d0925559fe7731034856a872708bcfb78be) | fix | restore console methods after logger completes | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [7170599ab](https://github.com/angular/angular-cli/commit/7170599ab237691d9208c410363ef7e4ee50db2c) | fix | deduplicate and merge coverage excludes with vitest | +| [c73f13797](https://github.com/angular/angular-cli/commit/c73f13797afe57fcc98faf6361085e1dd5afae9b) | fix | prevent reporter duplicates by explicitly overriding Vitest configuration | +| [956ccaa71](https://github.com/angular/angular-cli/commit/956ccaa71ea8a3626e4139cf7e2f26ee637feeed) | fix | remove default for unit-test coverage option | +| [36978db7e](https://github.com/angular/angular-cli/commit/36978db7e494e4e5612aa2a8384199eeca7c4c2d) | fix | warn about performance of test.exclude in vitest configuration | +| [6ec36f5be](https://github.com/angular/angular-cli/commit/6ec36f5bee05d97c10ca8d91d5746621ffb1fdb9) | fix | warn when vitest watch config conflicts with builder | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [9bdf782c8](https://github.com/angular/angular-cli/commit/9bdf782c838ab5820ec905d689a62ffc3b3cabe3) | fix | apply forwarded prefix and vary header in accept-language redirects | +| [628c58672](https://github.com/angular/angular-cli/commit/628c586728748e1c367fa7e363299eb79b1566ca) | fix | support '\*' in allowedHosts and warn about security risks | + + + + + +# 20.3.21 (2026-03-19) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [1dc6992a5](https://github.com/angular/angular-cli/commit/1dc6992a5ae6c5a1f16f22f6c94690d5cf218c38) | fix | disallow x-forwarded-prefix starting with a backslash | +| [0a2ff0b2b](https://github.com/angular/angular-cli/commit/0a2ff0b2b3aceb228c9447c19fb762df742d7265) | fix | ensure unique values in redirect response Vary header | +| [cdbac82a8](https://github.com/angular/angular-cli/commit/cdbac82a85b35f24c70a062eeb8a13b521831019) | fix | support custom headers in redirect responses | + + + + + +# 21.2.3 (2026-03-18) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [1505164bb](https://github.com/angular/angular-cli/commit/1505164bb2703254a2b25a76c7b3a1ff2fd76a85) | fix | use parsed package name for migrate-only updates | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------ | +| [75fa94cad](https://github.com/angular/angular-cli/commit/75fa94cad26b0947e687ef94d50653cb7651d18c) | fix | alias createRequire banner import to avoid duplicate binding | +| [d009aa1ec](https://github.com/angular/angular-cli/commit/d009aa1ec7411b67b61b81003eb6181cde6f306f) | fix | only use external packages for polyfills when no local files are present | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [f3e0e82c2](https://github.com/angular/angular-cli/commit/f3e0e82c2cecc3d9ebb5b8acc6e64d2d88c4efbd) | fix | disallow x-forwarded-prefix starting with a backslash | +| [b8bcd59b4](https://github.com/angular/angular-cli/commit/b8bcd59b40496369a57de0b0b39d85f323af30c7) | fix | ensure unique values in redirect response Vary header | +| [84385411d](https://github.com/angular/angular-cli/commit/84385411d4542d60d635aea9063c1fd751deb607) | fix | support custom headers in redirect responses | + + + + + +# 21.2.2 (2026-03-11) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------- | +| [8447d9132](https://github.com/angular/angular-cli/commit/8447d913280a8fa09a842d11193ce77527d0f7a6) | fix | conditionally quote package names when adding dependencies based on host requirements | +| [d2f209823](https://github.com/angular/angular-cli/commit/d2f209823a524a6effde4910017547675c7a6166) | fix | preserve exact version in ng add when requested | +| [28f4d684a](https://github.com/angular/angular-cli/commit/28f4d684ae12f0e0860bf0ace8851fdddad1c068) | perf | avoid redundant package version resolution in ng add | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [06010294f](https://github.com/angular/angular-cli/commit/06010294f8fe7a4843f802aafba51703ce810f61) | fix | allow any `CHROME_BIN` for vitest playwright provider | +| [8dec0c62b](https://github.com/angular/angular-cli/commit/8dec0c62ba40af339f4fd0fa34f20cbed545cd71) | fix | normalize line endings for CSP hash generation | +| [58688ebd7](https://github.com/angular/angular-cli/commit/58688ebd727fe295adcb538a33b525867caf82bd) | fix | pass process environment variables to prerender workers | +| [4ca61647f](https://github.com/angular/angular-cli/commit/4ca61647f208ec0ab9bc06f64583696b0619c259) | fix | resolve assets correctly during i18n prerendering | + + + + + +# 20.3.20 (2026-03-11) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [0fd6823af](https://github.com/angular/angular-cli/commit/0fd6823af0adec23f7c3f1d531f45f6432afe555) | fix | pass process environment variables to prerender workers | + + + + + +# 21.2.1 (2026-03-05) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [ae4c28d00](https://github.com/angular/angular-cli/commit/ae4c28d0083d948489f4ba38c571b7f955400226) | fix | correct dev dependency detection logic in `ng add` | +| [465073bc1](https://github.com/angular/angular-cli/commit/465073bc1b2b0e9fa594698651a9e0afe747a74a) | fix | disable npm update notifier in package manager host | +| [36270634f](https://github.com/angular/angular-cli/commit/36270634f6ff5ab15896a8c2b345659511a8a276) | fix | ensure group members are updated to targeted version | +| [d87dba6af](https://github.com/angular/angular-cli/commit/d87dba6af1116de0838d8683cd69fd31ed9811fd) | fix | ignore unknown files when formatting schematic changes | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [72d466aa0](https://github.com/angular/angular-cli/commit/72d466aa04d4d0cc4d654410bcb6dd44f0de3357) | fix | prevent adding test dependencies when minimal option is enabled | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [0019d1c8e](https://github.com/angular/angular-cli/commit/0019d1c8e1494295a754063dbf936e1cd40d05bd) | fix | update copy-webpack-plugin to v14.0.0 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------ | +| [6ad860863](https://github.com/angular/angular-cli/commit/6ad8608636ad48ae140cc7299a32e0358c761fcc) | fix | bundle polyfills to preserve execution order in dev server | +| [d17397375](https://github.com/angular/angular-cli/commit/d1739737564fbcc3e4c5a6c3369046cccf0f6120) | fix | conditionally allow `vi.mock` for non-relative imports | +| [0d49f86ed](https://github.com/angular/angular-cli/commit/0d49f86edf5592f0266c6d6689ab4d55b27b2d8d) | fix | resolve style include paths relative to `ng-package.json` in unit-test builder | +| [584f6a2d9](https://github.com/angular/angular-cli/commit/584f6a2d95ac4bdd9f20d918c6700ea79227cc92) | fix | treat empty browsers array as undefined in unit-test builder | +| [6699cdc9b](https://github.com/angular/angular-cli/commit/6699cdc9bfbabc3de2ff0cf03acfd6989dc5596c) | perf | fix memory leak in `ng serve` with i18n | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [43a9dfa66](https://github.com/angular/angular-cli/commit/43a9dfa663c386217c9a654f0e80af74823fcf6a) | fix | improve header validation logic | +| [dee3717b3](https://github.com/angular/angular-cli/commit/dee3717b3faae9ea75d0a5e53c925f915949b8d0) | fix | introduce DI token to signal route discovery process | + + + + + +# 20.3.19 (2026-03-04) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [0299b4d1a](https://github.com/angular/angular-cli/commit/0299b4d1aca13f11a06e2e92c593fe3e20906d23) | fix | update copy-webpack-plugin to v14.0.0 | + + + + + +# 20.3.18 (2026-02-26) + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------ | +| [39596d529](https://github.com/angular/angular-cli/commit/39596d529f831f72a2134bc3c9ac163867ff5702) | fix | update `ajv` to `8.18.0` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------- | +| [f668e2778](https://github.com/angular/angular-cli/commit/f668e2778c4c4dbecc8a1c6831c092f5512d1ec1) | fix | update rollup to 4.59.0 | + + + + + +# 19.2.22 (2026-02-26) + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------ | +| [0a01aecd9](https://github.com/angular/angular-cli/commit/0a01aecd92a5dfa1a254429481906a88be725c5e) | fix | update `ajv` to `8.18.0` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------- | +| [79f59412a](https://github.com/angular/angular-cli/commit/79f59412a17eeabd2ad5df1d614ed634354d1eee) | fix | update rollup to 4.59.0 | + + + + + +# 21.2.0 (2026-02-25) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------- | +| [0dd04f289](https://github.com/angular/angular-cli/commit/0dd04f289e555a4a8af7bdadabe300da74701e3b) | feat | add markdown files to Prettier's formatting list | +| [fbae1b6ab](https://github.com/angular/angular-cli/commit/fbae1b6ab384186ae69e804c54815cea80e6a600) | feat | automatic formatting files modified by schematics | +| [91b9d281f](https://github.com/angular/angular-cli/commit/91b9d281fc88a242aa6e5dd5495e275990d926ef) | feat | integrate file formatting into update migrations | +| [98a24d040](https://github.com/angular/angular-cli/commit/98a24d0401f36f484dc9c4d8b0f5284ffa524f19) | feat | standardize MCP tools around workspace/project options | +| [d9cd609c5](https://github.com/angular/angular-cli/commit/d9cd609c5d13fe492b1f31973d9be518f8529387) | fix | correctly parse scoped packages in yarn classic list output | +| [5b05f2500](https://github.com/angular/angular-cli/commit/5b05f25005621828565585692b1d7a67c5f0fec8) | fix | enable shell option for Prettier execution on Windows platforms | +| [25b8a157d](https://github.com/angular/angular-cli/commit/25b8a157df70fb0d2c4e6c5438a50ec12e3abc0c) | fix | quote complex range specifiers in package manager | +| [6f29a8c35](https://github.com/angular/angular-cli/commit/6f29a8c35abb8928d4e7ea01958192dd2a83491d) | fix | renamed files by their new path in the schematic workflow | +| [201a036f2](https://github.com/angular/angular-cli/commit/201a036f204a6940f70a36a507a4a53d144b5768) | fix | simplify Angular version compatibility checks and add special handling for local builds of new major versions | +| [cdd26bb66](https://github.com/angular/angular-cli/commit/cdd26bb66d8ab334f76323c2b5cae1aa8ce815f6) | fix | validate package manager version using `semver.valid` and throw an error if invalid | +| [bc363af8b](https://github.com/angular/angular-cli/commit/bc363af8bc40f117a4e35ec9eb7eedf69f5b5b37) | perf | optimize package manager discovery with stat-based probing | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------- | +| [aa7381efd](https://github.com/angular/angular-cli/commit/aa7381efd213eff70a8004731a7e2b06a60cb8c2) | feat | add a '.prettierrc' file to generated workspaces and add Prettier as dev dependency | +| [f80db6fb7](https://github.com/angular/angular-cli/commit/f80db6fb714aa326f6ed03a8a51090ca59ad0955) | feat | add ng-add support for Vitest browser providers | +| [5d1df50d8](https://github.com/angular/angular-cli/commit/5d1df50d8b84b453570ae5fd9ab6f949bbc11649) | fix | add actionable feedback to vitest-browser schematic | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [ece30f235](https://github.com/angular/angular-cli/commit/ece30f2359c2dc794b0c9272447f623a121e88b0) | feat | add headless option to unit-test builder | +| [cad7a7c0f](https://github.com/angular/angular-cli/commit/cad7a7c0ff3778f04820a99ad0aa9d74f1067fd5) | feat | run vitest browser with playwright with OS theme | +| [0b4982720](https://github.com/angular/angular-cli/commit/0b4982720e111bf5029bcf97f7e0ce2658c42d43) | fix | adjust sourcemap sources when Vitest wrapper is bypassed | +| [1f114a9e8](https://github.com/angular/angular-cli/commit/1f114a9e8b9bddd53e01016a2d7cb211a04eee48) | fix | bundle setup files in unit-test builder for Vitest | +| [fd5cb28c8](https://github.com/angular/angular-cli/commit/fd5cb28c8082417288a896b89bde659bb0dc92e2) | fix | explicitly fail when using Vitest runtime mocking | +| [dc899e8a5](https://github.com/angular/angular-cli/commit/dc899e8a530979de8e9579f2281b681e6f737a62) | fix | normalize `allowedHosts` in dev-server | +| [26bbea12f](https://github.com/angular/angular-cli/commit/26bbea12f872c18e59de05d3c51cc11dd0a09cda) | fix | serve extensionless assets without transformation | + + + + + +# 21.1.5 (2026-02-23) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [8695d6063](https://github.com/angular/angular-cli/commit/8695d6063e4c4123a37a3450c7db3a7c9d99050a) | fix | prevent open redirect via X-Forwarded-Prefix header | +| [e4d445ec6](https://github.com/angular/angular-cli/commit/e4d445ec674841dd1d374c83344c0a704ee3807c) | fix | validate host headers to prevent header-based SSRF | + + + + + +# 20.3.17 (2026-02-23) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [8700e18d7](https://github.com/angular/angular-cli/commit/8700e18d7cf175d80fe6ce6205589767b7870c1c) | fix | prevent open redirect via X-Forwarded-Prefix header | +| [67582a946](https://github.com/angular/angular-cli/commit/67582a946808d2c021cbcfacbf203ef58a6fbded) | fix | validate host headers to prevent header-based SSRF | + + + + + +# 19.2.21 (2026-02-23) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [288e22816](https://github.com/angular/angular-cli/commit/288e228161e3c0cceb392dd5fc24d07b129a3a64) | fix | prevent open redirect via X-Forwarded-Prefix header | +| [2a72d7483](https://github.com/angular/angular-cli/commit/2a72d7483d87ccdcfa0c5148f34a4c6ebb6c6cf9) | fix | validate host headers to prevent header-based SSRF | + + + + + +# 19.2.20 (2026-02-13) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [0e5421ba7](https://github.com/angular/angular-cli/commit/0e5421ba78814cf11e4d4510e930eaacc6458662) | fix | update webpack to 5.105.0 | + + + + + +# 21.1.4 (2026-02-11) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [7a9dd6b47](https://github.com/angular/angular-cli/commit/7a9dd6b47e2191862c64355b10abaeead189759f) | fix | correctly resolve absolute setup file paths in Vitest | + + + + + +# 20.3.16 (2026-02-09) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [656888a25](https://github.com/angular/angular-cli/commit/656888a250af060c110ae87024b0e475b079c23d) | fix | update dependency @modelcontextprotocol/sdk to v1.26.0 | + + + + + +# 21.1.3 (2026-02-05) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [a18196a10](https://github.com/angular/angular-cli/commit/a18196a1096e5eb69cf64102943781d34c4389bf) | fix | warn when production configuration is missing for service worker | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------- | +| [6d05d27ca](https://github.com/angular/angular-cli/commit/6d05d27ca097b16efb139bcee1c45b1b51dfe746) | fix | address Node.js deprecation DEP0190 | + + + + + +# 21.1.2 (2026-01-28) + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------- | +| [e7458c81d](https://github.com/angular/angular-cli/commit/e7458c81d669296c767fca272f80054d3b434a72) | fix | Add boolean type inference for 'true' and 'false' string values in argument parsing | + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------- | +| [d66f1fe64](https://github.com/angular/angular-cli/commit/d66f1fe647560498d78e9db362a5fdf1ab492326) | fix | Add boolean type inference for 'true' and 'false' string values in argument parsing | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [80911af67](https://github.com/angular/angular-cli/commit/80911af673365af3bcb86760bebb4200967ca433) | fix | loosen Vitest dependency checks when runnerConfig is used | +| [2d30639d3](https://github.com/angular/angular-cli/commit/2d30639d3c5a0eb3a1f40ec4cd8fe157f28f19f5) | fix | support merging coverage thresholds with Vitest runnerConfig | + + + + + +# 21.1.1 (2026-01-21) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [151b69587](https://github.com/angular/angular-cli/commit/151b69587d982130bb80461e4fe143c8f047e68d) | fix | Remove nonexistent link from MCP response | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [9da6d8fa7](https://github.com/angular/angular-cli/commit/9da6d8fa7640f321547b0308accbfcafefee5b0b) | fix | correct vscode MCP configuration for new projects | +| [361758c75](https://github.com/angular/angular-cli/commit/361758c75c08d80100ab2307578ef74e8ec288e2) | fix | remove special characters from jasmine-vitest report filename | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [1b7e3307a](https://github.com/angular/angular-cli/commit/1b7e3307afb21224ade885e5be64f16c1de860c9) | fix | allow application assets in workspace root | +| [d1e596dc5](https://github.com/angular/angular-cli/commit/d1e596dc53b9a43063308fa775cd9ad8f60cad8b) | fix | prevent incorrect catch binding removal in downleveled for-await | +| [98ef0981a](https://github.com/angular/angular-cli/commit/98ef0981a2e3aee3c6fd2b6cf0561f6919487723) | fix | update undici to v7.18.2 | + + + + + +# 20.3.15 (2026-01-21) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------ | +| [795d65413](https://github.com/angular/angular-cli/commit/795d654138701a03d4d793d3299ff4f33e427a03) | fix | update pacote to v21.0.4 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------- | +| [ffc72cbc5](https://github.com/angular/angular-cli/commit/ffc72cbc52e23cb545476b3fdefc7e5f170eb55d) | fix | update webpack to version 5.104.1 | + + + + + +# 21.1.0 (2026-01-14) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [772e6efe7](https://github.com/angular/angular-cli/commit/772e6efe7acb2d2318a57ba77092a85fc286c51b) | feat | add 'test' and 'e2e' MCP tools | +| [8efb86318](https://github.com/angular/angular-cli/commit/8efb8631842401e219e20dd7955512d4a90a28a3) | feat | Add "all" as an experimental tool group | +| [c3c9ac506](https://github.com/angular/angular-cli/commit/c3c9ac5067275461e2d8caefba81ac9701949776) | feat | Add MCP tools for building and running devservers | +| [d635a6c63](https://github.com/angular/angular-cli/commit/d635a6c6335d0838fc0977f6742f6aa9f769c527) | feat | add signal forms lessons | +| [d8b76e93d](https://github.com/angular/angular-cli/commit/d8b76e93d3e9e4e7bd7ad6e12fdf59cd663cbb8e) | fix | correctly handle yarn classic tag manifest fetching | +| [7ab5c0b0a](https://github.com/angular/angular-cli/commit/7ab5c0b0a1c637f3e0adb71486e5e7e8716561e4) | fix | correctly spawn package managers on Windows in new abstraction | +| [348096623](https://github.com/angular/angular-cli/commit/348096623326857a5d8cf77d56712776e1190180) | fix | enhance list_projects MCP tool file system traversal and symlink handling | +| [316fca862](https://github.com/angular/angular-cli/commit/316fca8626d51b28ea8cd840f3815b7c6dfcfffa) | fix | handle array output from npm view in manifest parser | +| [032257a6d](https://github.com/angular/angular-cli/commit/032257a6d00360d2c4e6d5406409dcfa5b27d1d5) | fix | improve signal forms lesson examples in AI tutor | +| [18d74dde8](https://github.com/angular/angular-cli/commit/18d74dde8938dbe566df80753d5c148c19040179) | fix | rename mcp devserver tools to comply with naming spec | +| [1ad773671](https://github.com/angular/angular-cli/commit/1ad773671afa2849a966f9974cb30e7c8e8ed7d4) | fix | update dependency @modelcontextprotocol/sdk to v1.25.2 | +| [45d4f5668](https://github.com/angular/angular-cli/commit/45d4f5668018362f90fcc4cdc487470286f03c02) | fix | update yarn berry package manager configuration | +| [122ed27c9](https://github.com/angular/angular-cli/commit/122ed27c906613b7966cbda8fe2f36dbe6f60198) | fix | use project-local temporary directory in ng add | +| [a15db28b2](https://github.com/angular/angular-cli/commit/a15db28b29f6f43bef1ed1ca7c6a963d9943f801) | perf | cache resolved specific version in package manager abstraction | +| [240588b7e](https://github.com/angular/angular-cli/commit/240588b7e3c8698c83110793ab98d20caee4e1a4) | perf | optimize `ng add` version discovery | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [36cf3afb4](https://github.com/angular/angular-cli/commit/36cf3afb485a01f86c4c90f136b38a3cf338e313) | feat | add browserMode option to jasmine-vitest schematic | +| [e71a72ffd](https://github.com/angular/angular-cli/commit/e71a72ffdc426e26bfb4f0bb92e8f5795a621c18) | feat | generate detailed migration report for `refactor-jasmine-vitest` | +| [18cf6c51b](https://github.com/angular/angular-cli/commit/18cf6c51b72ce5c7f23012585ed992cf91cef5ed) | fix | add MCP configuration file to new workspaces | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------- | +| [1eda0a99f](https://github.com/angular/angular-cli/commit/1eda0a99f89f625f8db1ecfe4873a7672e625401) | feat | directly support ng-packagr in unit-test builder | +| [87175f9dc](https://github.com/angular/angular-cli/commit/87175f9dcdb7349dc8701fa1d5cf61c1b8542d42) | feat | disable TestBed teardown during debugging in Vitest | +| [1e39c77a4](https://github.com/angular/angular-cli/commit/1e39c77a4fe272ccab1a1d8bd58eef1ce608a6c7) | fix | inject source-map-support for Vitest browser tests | +| [3fd7dcd76](https://github.com/angular/angular-cli/commit/3fd7dcd764be0d0afb9cd792d53268d6f314df83) | fix | normalize roots to POSIX in test discovery for Windows compatibility | +| [164e7dbbc](https://github.com/angular/angular-cli/commit/164e7dbbc2b06bbd5aab84937c903e0590591c60) | fix | resolve test files correctly on Windows when using non-C drives | +| [ad99e00ad](https://github.com/angular/angular-cli/commit/ad99e00ad7edd17e369777c8d38b4137ea736121) | fix | simplify SSL handling for `ng serve` with SSR ([#31722](https://github.com/angular/angular-cli/pull/31722)) | + + + + + +# 21.0.6 (2026-01-14) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [730ae6609](https://github.com/angular/angular-cli/commit/730ae6609b847802124a5c6e12c77522af54b967) | fix | handle platform destruction during rendering | + + + + + +# 21.0.5 (2026-01-07) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [249563749](https://github.com/angular/angular-cli/commit/24956374941f3753a24470bfdafaf6d3645a3ddb) | fix | use narrower types for new MCP TS SDK compatibility | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------- | +| [cbd0718b9](https://github.com/angular/angular-cli/commit/cbd0718b93c0d51331af772f578b6e0dc3dfbe9e) | fix | move 'provideZoneChangeDetection' to the root module | +| [33f7cf761](https://github.com/angular/angular-cli/commit/33f7cf761aa222e1754e039533831c729895e5e6) | fix | update application schematics for module-based apps to use 'provideZoneChangeDetection' | +| [37b14d1f7](https://github.com/angular/angular-cli/commit/37b14d1f7b18a607ada3e9a113c16f5e8a7669ef) | fix | update default app component message | +| [c37dccb09](https://github.com/angular/angular-cli/commit/c37dccb093f7b48027fac27bd78abe7f3e3e15ad) | fix | update default app component welcome message | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [2b9be3a7c](https://github.com/angular/angular-cli/commit/2b9be3a7cadc444e67d629c2a74c54f0c65f4a54) | fix | ensure correct project targeting during Vitest debugging | + + + + + +# 20.3.14 (2026-01-07) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [ff366499e](https://github.com/angular/angular-cli/commit/ff366499eff87e9943e25904fd06d109a0fa0075) | fix | update dependency @modelcontextprotocol/sdk to v1.25.2 | + + + + + +# 21.0.4 (2025-12-18) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [b671245b9](https://github.com/angular/angular-cli/commit/b671245b9d3ba98ac0f66dbd34f272539113be61) | fix | improve VS Code background compilation start/end detection | +| [85a28dec7](https://github.com/angular/angular-cli/commit/85a28dec771cce77a3ffee35f419b5fedca807b8) | fix | remove `inlineSources` from library tsconfig template | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [deb4fff61](https://github.com/angular/angular-cli/commit/deb4fff6196d2eb147e358a7143e2ada2b6114c9) | fix | add browser condition to resolver for vitest | +| [570ce8d3e](https://github.com/angular/angular-cli/commit/570ce8d3eeb280eeb6dca6ba54593c9325674741) | fix | allow non-prefixed requests when using SSR and base href | +| [4dd3c1a32](https://github.com/angular/angular-cli/commit/4dd3c1a324c8e90808cc1c5febf65c8fa49dd3b9) | fix | conditionally manage Vitest UI option | +| [4b8b7caec](https://github.com/angular/angular-cli/commit/4b8b7caece41f86746321a98786dfdff499582b6) | fix | ensure tests run when compilation error is resolved | +| [bef4fcecb](https://github.com/angular/angular-cli/commit/bef4fcecb6d116f9f022da845f06708cf29be02a) | fix | remove LmdbCacheStore export from private API | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [bb54747da](https://github.com/angular/angular-cli/commit/bb54747da69fb15b6c2ebb52b45a83cbff3231c8) | fix | add leading slash to well-known non-Angular URLs | +| [0cfe2e749](https://github.com/angular/angular-cli/commit/0cfe2e749f50b832c64bbba322eb0cef7ad40365) | fix | propagate status code to redirect | +| [eadadb848](https://github.com/angular/angular-cli/commit/eadadb848ca7fa45c4dda835af39400e017bbe1c) | fix | skip SSR processing for well-known non-Angular URLs like favicon.ico | + + + + + +# 21.0.3 (2025-12-10) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [5d85f416f](https://github.com/angular/angular-cli/commit/5d85f416f43b6bcd07b28ab920cb40c61a83ebdd) | fix | conditionally provide Zone.js change detection in the built-in test main file | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------- | +| [778b4cffc](https://github.com/angular/angular-cli/commit/778b4cffc03e7c137940e3b8c89f290fd226cf17) | fix | Add custom middleware for to present an Angular-tailored message | +| [9b02ab2ee](https://github.com/angular/angular-cli/commit/9b02ab2ee0a36aa6aafd94ea8059b48679845860) | fix | Ensure disposal of close-javascript-transformer | +| [0fc7d576e](https://github.com/angular/angular-cli/commit/0fc7d576e53f45601fdbeb95f4a853ebceae4fad) | fix | ensure locale base href retains leading slash ([#32040](https://github.com/angular/angular-cli/pull/32040)) | +| [b141670a2](https://github.com/angular/angular-cli/commit/b141670a2453dd0ea5fe6aa22ddae7175893d813) | fix | inject testing polyfills in Karma unit-test executor | +| [88c18ce68](https://github.com/angular/angular-cli/commit/88c18ce68585726652b88b10ce090039fbe1829f) | fix | support NODE_EXTRA_CA_CERTS in SSR SSL plugin | + + + + + +# 21.0.2 (2025-12-03) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [f1a7116cd](https://github.com/angular/angular-cli/commit/f1a7116cdff1bd83b26b0d64cea14ec4e8084583) | fix | update `@modelcontextprotocol/sdk` to v1.24.0 | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------- | +| [dc6d9469e](https://github.com/angular/angular-cli/commit/dc6d9469ea494bbfee7da191774e9fa3c0baf30a) | fix | remove lazy imports in node tasks | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [f8a1939fd](https://github.com/angular/angular-cli/commit/f8a1939fdf5abbc1de439d288d9357d4f92a72a9) | fix | add filename truncation to test discovery | +| [86dd3297f](https://github.com/angular/angular-cli/commit/86dd3297f7ad81788713cfd8dae48c0fad4b89ab) | fix | allow overriding Vitest coverage `reportsDirectory` option | + + + + + +# 20.3.13 (2025-12-03) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [cfbb61602](https://github.com/angular/angular-cli/commit/cfbb61602daf32c5b942ea84702fc3638aa111e7) | fix | update `@modelcontextprotocol/sdk` to v1.24.0 | + + + + + +# 21.0.1 (2025-11-26) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------- | +| [363496ae0](https://github.com/angular/angular-cli/commit/363496ae0d2850545274cd7fe4dc6902ccb64e10) | fix | ensure dependencies are resolved correctly for node modules directory check | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [2f58705cb](https://github.com/angular/angular-cli/commit/2f58705cb5389019ceb49616a0e4ec3f92a558ed) | fix | add missing imports for lifecycle hooks in jasmine-vitest migration | +| [c973bb9ca](https://github.com/angular/angular-cli/commit/c973bb9cafc8d59b901a9d763347f4b615257867) | fix | add mock names to createSpyObj transformation | +| [4534c9848](https://github.com/angular/angular-cli/commit/4534c9848745eea516bdb58d86914252c35b5b9c) | fix | do not set `esModuleInterop` and `moduleResolution` when module is `preserve` | +| [16d898e75](https://github.com/angular/angular-cli/commit/16d898e7587036d68786cebe764da08304559d41) | fix | fix migration of `jasmine.clock().mockDate()` | +| [21c3eac72](https://github.com/angular/angular-cli/commit/21c3eac726c198132af760ffacc0dab9dfccb430) | fix | handle createSpyObj without base name on refactor-jasmine-vitest | +| [b8c99aa4c](https://github.com/angular/angular-cli/commit/b8c99aa4c909647285d1dcc61a2bb97a36100c63) | fix | improve safety of done callback transformation | +| [4a71e06fc](https://github.com/angular/angular-cli/commit/4a71e06fcafaadbcb820d285c0c186aa0e92f158) | fix | silently skip when the build target already uses one of the new builders | +| [2ffdae421](https://github.com/angular/angular-cli/commit/2ffdae42149b0f3da44f96271af1bca6d09b7ed5) | fix | support testRunner option in library schematic | +| [145de4a58](https://github.com/angular/angular-cli/commit/145de4a584ce8f72746704547282299306d9bafb) | fix | warn about loose matching in arrayWithExactContents | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [d097df2d7](https://github.com/angular/angular-cli/commit/d097df2d7088dd2bb97643c3acfc1f977a767dd9) | fix | correct Vitest coverage path resolution for JSDOM on Windows | +| [cdb607ada](https://github.com/angular/angular-cli/commit/cdb607ada4bf9aaec6ed8aafd8826d782fd13109) | fix | correctly configure per-browser headless mode in Vitest runner | +| [244931ece](https://github.com/angular/angular-cli/commit/244931ece877a1cacd1cfce64314e04a52526f80) | fix | correctly invoke `isTTY` as a function | +| [54d542738](https://github.com/angular/angular-cli/commit/54d542738e23c275ac6827f19da92213c405f9e2) | fix | ensure correct URL joining for prerender routes | +| [a28b38bbe](https://github.com/angular/angular-cli/commit/a28b38bbeba0977e99142a15d1ecc77c15abc416) | fix | force dev-server to use HTTP/1.1 when using SSR with SSL | +| [59ff867f0](https://github.com/angular/angular-cli/commit/59ff867f0d2e7f7f88480deefa0ee470c037197a) | fix | normalize `--include` paths to posix | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [03e231216](https://github.com/angular/angular-cli/commit/03e231216d3b8ba0de81da53a446eff0c701658d) | fix | handle `X-Forwarded-Prefix` and `APP_BASE_HREF` in redirects | +| [3cac01882](https://github.com/angular/angular-cli/commit/3cac0188271175e12cc238c6610b542f3ae14db3) | fix | prevent redirect loop with encoded query parameters | + + + + + +# 20.3.12 (2025-11-25) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [25bb7e65c](https://github.com/angular/angular-cli/commit/25bb7e65c4fc7e401c658126c53b0b7a13d62965) | fix | ensure correct URL joining for prerender routes | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [cceb86296](https://github.com/angular/angular-cli/commit/cceb862969e541a5f54b689a6439e32773eafe65) | fix | handle `X-Forwarded-Prefix` and `APP_BASE_HREF` in redirects | +| [1abe68ad8](https://github.com/angular/angular-cli/commit/1abe68ad87f9b892734117a087b5775068bd232b) | fix | prevent redirect loop with encoded query parameters | + + + + + +# 21.0.0 (2025-11-19) + +## Breaking Changes + +### @angular/cli + +- The `ng` commands will no longer automatically detect and use `cnpm` as the package manager. As an alternative use the `.npmrc` file to ensure npm uses the cnpm registry. + +### @angular/build + +- - TypeScript versions older than 5.9 are no longer supported. +- The `javascriptEnabled` option for Less is no longer supported. Projects relying on inline JavaScript within Less files will need to refactor their stylesheets to remove this dependency. + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [e417c89f9](https://github.com/angular/angular-cli/commit/e417c89f9e9cfe0ce50ffbc72ef555793605aea1) | feat | Add `addTypeToClassName` option to relevant schematics | +| [ede5e52bc](https://github.com/angular/angular-cli/commit/ede5e52bc701c42948bd98826cd4fa901350015c) | feat | add `include` option to jasmine-to-vitest schematic | +| [c119910f4](https://github.com/angular/angular-cli/commit/c119910f4505e280eea83ea6647b6d279a46f36d) | feat | add AGENTS.md support to ai-config schematic | +| [d0d2a17b8](https://github.com/angular/angular-cli/commit/d0d2a17b8adb2c1ce6eee70494f5d2298622c40e) | feat | add Jasmine spy API transformations to jasmine-to-vitest schematic | +| [e7d955bed](https://github.com/angular/angular-cli/commit/e7d955bedd5ca6957903cb73f8ebe06823a808da) | feat | add matcher transformations to jasmine-to-vitest schematic | +| [629f5cb18](https://github.com/angular/angular-cli/commit/629f5cb181fee562645baf02b44ebb3b39f3fb06) | feat | add misc transformations to jasmine-to-vitest schematic | +| [4912f3990](https://github.com/angular/angular-cli/commit/4912f39906b11a3212f11d5a00d577e2a0bacab4) | feat | add Tailwind CSS option to application schematic and `ng new` | +| [2a518016d](https://github.com/angular/angular-cli/commit/2a518016d9585dd4d16f90102d5409459ebba024) | feat | Applications are zoneless by default | +| [2ffc527b1](https://github.com/angular/angular-cli/commit/2ffc527b1bbe237e9f732d3506ce3a75ca1ca9d0) | feat | configure Vitest for new projects and allow runner choice | +| [58474ec7d](https://github.com/angular/angular-cli/commit/58474ec7dd85fc34639c138d9b8d545affb50e3e) | feat | introduce initial jasmine-to-vitest unit test refactor schematic | +| [9f255f2b3](https://github.com/angular/angular-cli/commit/9f255f2b3cc435f3bea2f0266a137176ca599aef) | feat | set `packageManager` in `package.json` on new projects | +| [4e6c94f21](https://github.com/angular/angular-cli/commit/4e6c94f21e882c593cf11197900c29d693af9297) | feat | support different file name style guides in `ng new` | +| [77741f5ee](https://github.com/angular/angular-cli/commit/77741f5eec735f23b0f2865101471e045e4889b8) | fix | add 'update-typescript-lib' migration | +| [f89750b27](https://github.com/angular/angular-cli/commit/f89750b27866c307da546fe4f33da980693ca5c1) | fix | add `addImports` option to jasmine-vitest schematic | +| [9dab5780a](https://github.com/angular/angular-cli/commit/9dab5780a1befbd76ee9ba4c4e6ac2d3fd714bb9) | fix | add fixture.whenStable in spec files when zoneless apps | +| [8f0f6a5f1](https://github.com/angular/angular-cli/commit/8f0f6a5f113ffc9e81d99eeeba71f8054e2d3686) | fix | add migration to update `moduleResolution` to `bundler` | +| [e8feba9ee](https://github.com/angular/angular-cli/commit/e8feba9ee163f688c51d6463336474591e886647) | fix | add missing typeSeparator to main.ts.template file | +| [515b09c4f](https://github.com/angular/angular-cli/commit/515b09c4f28ef1c2eb911cb73135a6086dcab3c9) | fix | add Vitest config generation and runner checks | +| [0e83fe1a8](https://github.com/angular/angular-cli/commit/0e83fe1a87cc3dcbc9daa4440a050ae6aafc8042) | fix | add warnings and improve Karma config generation | +| [b91fa31f2](https://github.com/angular/angular-cli/commit/b91fa31f20b49ead021c72c271f67da38b340584) | fix | align Karma project generation with unified unit-test builder | +| [c967a447c](https://github.com/angular/angular-cli/commit/c967a447ce755fbf582ec35aa24bb6e0fa0043cf) | fix | correct spacing in application spec tsconfig | +| [00d941c43](https://github.com/angular/angular-cli/commit/00d941c433de718cf3c38033d5d68dd86f790291) | fix | correct style guide paths for standalone components | +| [e33e77d12](https://github.com/angular/angular-cli/commit/e33e77d12984446fe7bc77deb7438806817ba8a7) | fix | flag '--file-name-style-guide=2016' - wrong import in main.ts | +| [f35b9f331](https://github.com/angular/angular-cli/commit/f35b9f3310995b05d501f2abaec58dcd283e3aa0) | fix | improve comment preservation in jasmine-to-vitest | +| [6615fcf03](https://github.com/angular/angular-cli/commit/6615fcf037686cd96e97b469937b7f0736afaa77) | fix | issues in apps generated with '--file-name-style-guide=2016' flag | +| [e304821d5](https://github.com/angular/angular-cli/commit/e304821d5d789fab2725d3152612d3e5b6bd0dc7) | fix | make ai-config schematic non-destructive | +| [512ad282a](https://github.com/angular/angular-cli/commit/512ad282aecbfdf1e5c9e9700cc722addb949b68) | fix | preserve blank lines in jasmine-to-vitest schematic | +| [b524ba426](https://github.com/angular/angular-cli/commit/b524ba42625cd690177a300ca4843ef4edce035f) | fix | remove empty i18n-extract target for new projects | +| [8e6e0a293](https://github.com/angular/angular-cli/commit/8e6e0a2931bfb178e77cf2c9ca7f92a56c673449) | fix | remove explicit flag for host bindings | +| [afb4d3e37](https://github.com/angular/angular-cli/commit/afb4d3e377b11315a03563cb8c143c35d37f113a) | fix | remove extra space before async in spec templates | +| [b983ea8e5](https://github.com/angular/angular-cli/commit/b983ea8e5107420a910dbbc05c6b74f0ff6fbddd) | fix | respect skip-install for tailwind schematic | +| [54c4eae2a](https://github.com/angular/angular-cli/commit/54c4eae2aa49c6b45c41f0718a5915a10d426cb4) | fix | transform Jasmine type annotations in jasmine-to-vitest schematic | +| [14c0a9bac](https://github.com/angular/angular-cli/commit/14c0a9bacbb66b1db714ea7906c7d33f49c710fc) | perf | optimize AST traversal utilities | + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [58d101d5e](https://github.com/angular/angular-cli/commit/58d101d5e78cf4c158dbaf52103639d56b84f9ed) | feat | add `--json` output to `ng version` | +| [d014630fa](https://github.com/angular/angular-cli/commit/d014630fad765ae3928b698122038cbe00d37102) | feat | add advanced filtering to MCP example search | +| [6d3a3c579](https://github.com/angular/angular-cli/commit/6d3a3c5799bde1bab5c3878e0783ffa6854e36ad) | feat | add ai-tutor mcp tool | +| [1c06b16a9](https://github.com/angular/angular-cli/commit/1c06b16a962d3c2cc122dc40e01c64bc8a8d754d) | feat | add builder info to `list_projects` MCP tool | +| [301b50da4](https://github.com/angular/angular-cli/commit/301b50da4cf99b3cd87940606121d076b4f241c6) | feat | add fallback support for packages without direct `ng add` functionality | +| [3040b777e](https://github.com/angular/angular-cli/commit/3040b777e40bc90fd1ed961e3d134875b3f9b464) | feat | add style language detection to list_projects tool | +| [45024e836](https://github.com/angular/angular-cli/commit/45024e836b4006cc48b18bb99d025ae1a572db12) | feat | add unit test framework detection to list_projects tool | +| [104c90768](https://github.com/angular/angular-cli/commit/104c90768000b3e0052ee7e7de2c5e04c1bffdaf) | feat | enhance `ng version` output with more details | +| [286b6204c](https://github.com/angular/angular-cli/commit/286b6204c825c990761a0d5e5b91bb439dd13655) | feat | make documentation search tool version-aware | +| [406315d09](https://github.com/angular/angular-cli/commit/406315d0939c62d9f4f39ce64b168e72bbdd588c) | feat | make find_examples tool version-aware | +| [68e711307](https://github.com/angular/angular-cli/commit/68e711307eae88a621698c2a9cc2abc30d44efc8) | feat | make get_best_practices tool version-aware | +| [50453fdee](https://github.com/angular/angular-cli/commit/50453fdeec4a00d88deada49d2dd0867bdb784fb) | feat | overhaul `ng version` command output | +| [1ee9ce3c9](https://github.com/angular/angular-cli/commit/1ee9ce3c93caff419f8095a91cf58601e3df3f74) | feat | promote MCP `find_examples` tool to a stable tool | +| [0d53e82d5](https://github.com/angular/angular-cli/commit/0d53e82d5ed8986603c2005fc06041dd076b08c6) | feat | provide detailed peer dependency conflict errors in ng add | +| [f513089e2](https://github.com/angular/angular-cli/commit/f513089e276acf5a7c4f6879a95e2d6ed78ae67d) | feat | remove direct support for `cnpm` | +| [c17d7a929](https://github.com/angular/angular-cli/commit/c17d7a929adccb77f3c2c33e70005f50032d8cae) | fix | add schema versioning and metadata to example database | +| [dbf1aaf70](https://github.com/angular/angular-cli/commit/dbf1aaf70bc3e3dd0de05d760bafacc43b34dce8) | fix | add snippet support to example search MCP tool | +| [dfb4242b3](https://github.com/angular/angular-cli/commit/dfb4242b347365f3a2c6d006f07a16c982ff4dbe) | fix | add vitest to version command output | +| [11cee1acb](https://github.com/angular/angular-cli/commit/11cee1acb59afbad1ef88d8340b5438f7dbefe57) | fix | correct boolean parsing in MCP example front matter | +| [122a8c0e2](https://github.com/angular/angular-cli/commit/122a8c0e27342db79eb4d38e23032548054709b9) | fix | correct frontmatter parsing in MCP examples tool | +| [431106559](https://github.com/angular/angular-cli/commit/431106559d6e75f5113876a3f92fdf4dc4b2114d) | fix | correct query in find_examples to prevent runtime error | +| [def412a55](https://github.com/angular/angular-cli/commit/def412a558d71cb51fa16d826418bd0ed0a085cf) | fix | enhance find_examples MCP tool with structured output | +| [0922a033f](https://github.com/angular/angular-cli/commit/0922a033f546b38f83d1cae524cf7237dd37a2ac) | fix | improve JSON schema parsing for command options | +| [f099c9157](https://github.com/angular/angular-cli/commit/f099c91570b3cd748d7138bd18a4898a345549db) | fix | improve list_projects MCP tool to find all workspaces in monorepos | +| [1be35b343](https://github.com/angular/angular-cli/commit/1be35b3433179481be85ea1cb892d66170e0aebe) | fix | promote zoneless migration MCP tool to stable | +| [e5aed6d65](https://github.com/angular/angular-cli/commit/e5aed6d655ed92ea6eb3ac03716b8a02a5f731d6) | fix | show planned actions in `ng add` dry run | +| [4deac3ec7](https://github.com/angular/angular-cli/commit/4deac3ec785b1a53156aac90441d0ed129df71ef) | fix | support multi-database search in find_examples MCP tool | +| [aeb49dd52](https://github.com/angular/angular-cli/commit/aeb49dd52bf88785a193fcb6caa0b36aaeef1d37) | perf | cache dependency lookups during `ng add` | +| [5e534090e](https://github.com/angular/angular-cli/commit/5e534090e25e00a9fafbce2867030e7fdb0efbf6) | perf | parallelize peer dependency checks in `ng add` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [6e395fc0c](https://github.com/angular/angular-cli/commit/6e395fc0c4505dd32b3237ea116e8db6bde25758) | fix | ensure vitest code coverage handles virtual files correctly | +| [53899511a](https://github.com/angular/angular-cli/commit/53899511afe5665541984085914a313390af6ce2) | fix | expand `jest` and `jest-environment-jsdom` to allow version 30 | +| [7a8c94615](https://github.com/angular/angular-cli/commit/7a8c94615164e114533fae0f84796a374dc1b47b) | fix | make zone.js optional in server and app-shell builders | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------- | +| [00426e315](https://github.com/angular/angular-cli/commit/00426e3150c846913a5aa31510b5a1126df9e570) | feat | add --list-tests flag to unit-test builder | +| [a908bf3d4](https://github.com/angular/angular-cli/commit/a908bf3d4e8a59f3546f117bcc1f12fd69ad2d0b) | feat | add 'filter' option to unit-test builder | +| [3e0209d0a](https://github.com/angular/angular-cli/commit/3e0209d0a6bc6d7985d6294fc1430cdbe4d2d9a6) | feat | add `browserViewport` option for vitest browser tests | +| [3b7dabbf1](https://github.com/angular/angular-cli/commit/3b7dabbf1df9b2b6ca9ffc6c038abb6e40b6df2b) | feat | add advanced coverage options to unit-test builder | +| [c0b00d78e](https://github.com/angular/angular-cli/commit/c0b00d78ec37426f4474f473ddf9e627a0dd23df) | feat | add reporter output file option for unit-test | +| [66dd6dd83](https://github.com/angular/angular-cli/commit/66dd6dd835d6b489e6b4be2138aa443e11bfa076) | feat | allow options for unit test reporters | +| [a90bea5b5](https://github.com/angular/angular-cli/commit/a90bea5b51c6978441919ed2af85c090fe99fd38) | feat | support `.test.ts` files by default in unit test builder | +| [b2f048773](https://github.com/angular/angular-cli/commit/b2f048773c6014022983e7ccc52cb760619d8a1b) | fix | add --ui option for Vitest runner | +| [530d9270e](https://github.com/angular/angular-cli/commit/530d9270e87786594dd8d1956b0806a28650db25) | fix | add `define` option to dev-server | +| [b554bd73a](https://github.com/angular/angular-cli/commit/b554bd73a9c248d986ed718028edf52ab5da6ccf) | fix | add temporary directory cleanup for Vitest executor | +| [c6176f6df](https://github.com/angular/angular-cli/commit/c6176f6dffdae5c8d8708a1dd20fb51ca72e3c24) | fix | add upfront dependency validation for unit-test runners | +| [63c98741a](https://github.com/angular/angular-cli/commit/63c98741adcd21701b8bc572e9410cc1cf4f91c3) | fix | add webcontainer support for Vitest browser provider | +| [fcdbf6c19](https://github.com/angular/angular-cli/commit/fcdbf6c19b5a8549011aebec9e517feb12a99895) | fix | allow `globals` to be set to false | +| [542d52868](https://github.com/angular/angular-cli/commit/542d528683cc0e51fd5a55ed6dbf43168f7a51a5) | fix | allow custom runner configuration file for unit-test | +| [0505f954d](https://github.com/angular/angular-cli/commit/0505f954dcf3b3339749ff461592d46d8ecc5e23) | fix | allow unit-test progress option passthrough for building | +| [931c62d20](https://github.com/angular/angular-cli/commit/931c62d20915c6c772b61d76ab88657c0858f6bd) | fix | allow unit-test runner config with absolute path | +| [a11dd31f0](https://github.com/angular/angular-cli/commit/a11dd31f0c80a189e7dcb3172c89a731cfc34702) | fix | configure Vitest cache to use Angular cache | +| [abf003268](https://github.com/angular/angular-cli/commit/abf003268c6cb18f0944665b0b3f2794c9469c3e) | fix | correct Vitest builder watch mode execution | +| [f05ffd104](https://github.com/angular/angular-cli/commit/f05ffd104255e86fe93f3736e1430f940cb83007) | fix | correct Vitest coverage include handling for virtual files | +| [cd5c92b99](https://github.com/angular/angular-cli/commit/cd5c92b99a5d8e9cb991a2551f564353c3df0fbe) | fix | correct Vitest coverage reporting for test files | +| [07f712253](https://github.com/angular/angular-cli/commit/07f712253bb6c37d27ae7be9845f497002b0780c) | fix | correctly handle absolute paths and casing in test discovery | +| [bf468e1eb](https://github.com/angular/angular-cli/commit/bf468e1eb1050c60359b6f52692ce0db286982c2) | fix | direct check include file exists in unit-test discovery | +| [50e330d33](https://github.com/angular/angular-cli/commit/50e330d331fc8cfc4c12f7258012305ecb419d2d) | fix | disable glob directory expansion when finding tests | +| [49b65aba8](https://github.com/angular/angular-cli/commit/49b65aba8d7cd2839776e987366b981d7762760c) | fix | disable Vitest test isolation by default | +| [1529595d4](https://github.com/angular/angular-cli/commit/1529595d4a8d8ff9251d1680b1a23bf4ef817db0) | fix | drop support for TypeScript 5.8 | +| [a44f8fa94](https://github.com/angular/angular-cli/commit/a44f8fa94bbf6ce8cdee05552dc56124507c6971) | fix | dynamically select Vitest DOM environment | +| [ae35543af](https://github.com/angular/angular-cli/commit/ae35543af7f5b3a5328c39fd4617d61b48067357) | fix | enhance Vitest config merging and validation | +| [fec106b60](https://github.com/angular/angular-cli/commit/fec106b60553394aab51d713e5437a713085089b) | fix | enhance Vitest dependency externalization and pre-bundling | +| [f7c4a4c1d](https://github.com/angular/angular-cli/commit/f7c4a4c1dcd575dec82cc06597e3d6620b1be729) | fix | enhance Vitest resolution for optimal package loading | +| [ee5e127d5](https://github.com/angular/angular-cli/commit/ee5e127d551269fa9a3e39b9b28e38d7ab35806f) | fix | ensure `ɵgetOrCreateAngularServerApp` is always defined after errors | +| [0830f4fb5](https://github.com/angular/angular-cli/commit/0830f4fb549e2c45b1ef752dd42f002a1347d7c8) | fix | ensure TestBed cleanup hooks are always registered | +| [41b12509a](https://github.com/angular/angular-cli/commit/41b12509a9db8bca637e0c67d21301a75774129c) | fix | ensure TestBed setup is robust in non-isolated Vitest | +| [55145f582](https://github.com/angular/angular-cli/commit/55145f582253b4ecb47add7ff2ef459b7535dfdb) | fix | ensure Vitest setup files are executed in order | +| [3478aa332](https://github.com/angular/angular-cli/commit/3478aa332ef0241c04e7eeef9dd74b017292b2c4) | fix | exclude .angular from coverage instrumentation | +| [7c529c1bc](https://github.com/angular/angular-cli/commit/7c529c1bc606101ab8c506e0b7845d2e9f509db4) | fix | externalize Angular dependencies in Vitest runner | +| [69c3b1226](https://github.com/angular/angular-cli/commit/69c3b1226880835fd8087cea5684ababb92b1c05) | fix | improve error handling in unit-test builder | +| [bab5806c2](https://github.com/angular/angular-cli/commit/bab5806c281fd4cdd63b7969e691d703ed1e7680) | fix | introduce vitest-base.config for test configuration | +| [73621998f](https://github.com/angular/angular-cli/commit/73621998f91db189ad9b1ab006681404e30f7900) | fix | normalize paths for Vitest runner output files | +| [fa5c92346](https://github.com/angular/angular-cli/commit/fa5c92346d14a6ad03aa30ad6392fc649038605e) | fix | prioritize string type for runnerConfig schema | +| [d0787c11d](https://github.com/angular/angular-cli/commit/d0787c11d68841c36ef28bc3f15963406d1209a9) | fix | provide default excludes for vitest coverage | +| [ac10f323e](https://github.com/angular/angular-cli/commit/ac10f323ece9f4a35068e510f10786fbcb15adbb) | fix | relax requirement for files to be in TS compilation | +| [139758586](https://github.com/angular/angular-cli/commit/13975858683421a5712bbfccee57cf141a0b96f6) | fix | remove deprecated `javascriptEnabled` option for Less | +| [6576bb598](https://github.com/angular/angular-cli/commit/6576bb5985c18dca7cecd9509939c2a78bf9758a) | fix | remove explicit test isolation configuration | +| [9132e6af9](https://github.com/angular/angular-cli/commit/9132e6af9fd573d8b39c69a50b4b93e256145fd4) | fix | resolve browser provider packages using project resolver | +| [26127bd3b](https://github.com/angular/angular-cli/commit/26127bd3bb2c4b9aacf2a8f4c2cbdf732512bafb) | fix | resolve PostCSS plugins relative to config file | +| [dae732059](https://github.com/angular/angular-cli/commit/dae732059d17e9e374ac7635fbca9480751f70b3) | fix | serve build assets and styles in vitest | +| [705af2278](https://github.com/angular/angular-cli/commit/705af22788102eeade08404d357582c39de8900b) | fix | set coverage report directory to coverage/project-name | +| [0851d2eae](https://github.com/angular/angular-cli/commit/0851d2eae1e5b854a0a8a7df3a47b00693508a0f) | fix | show full aggregate errors from vitest | +| [cc2668f57](https://github.com/angular/angular-cli/commit/cc2668f5744588f9c3d847d2450dd1361e73c690) | fix | simplify SSL handling for `ng serve` with SSR ([#31723](https://github.com/angular/angular-cli/pull/31723)) | +| [907eabdd3](https://github.com/angular/angular-cli/commit/907eabdd3c7447ed2c211b6d6c2719b04443c545) | fix | support ESM PostCSS plugins | +| [62938e799](https://github.com/angular/angular-cli/commit/62938e79977d14045b7883d459d786dbb8d4d7ee) | fix | update vitest to 4.0.6 and remove coverage workaround | + + + + + +# 20.3.11 (2025-11-19) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [8053f2d92](https://github.com/angular/angular-cli/commit/8053f2d92a68a8bd01854eb81aa472249f5a83b2) | fix | ensure `ɵgetOrCreateAngularServerApp` is always defined after errors | + + + + + +# 20.3.10 (2025-11-12) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [c854a719b](https://github.com/angular/angular-cli/commit/c854a719bb3a8d92fe42c8fba4b0b1789081b21c) | fix | correct `tsconfig.spec.json` include for spec files | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------- | +| [b3908f68e](https://github.com/angular/angular-cli/commit/b3908f68ed2f05cee56cbf0d9895dcfc3dc0ac25) | fix | do not remove `@angular/localize` when having external packages ([#31721](https://github.com/angular/angular-cli/pull/31721)) | + + + + + +# 20.3.9 (2025-11-05) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [08e07e338](https://github.com/angular/angular-cli/commit/08e07e338edd799400e18cd62cd131b6d408f4cf) | fix | improve locale handling in app-engine | +| [683697ebc](https://github.com/angular/angular-cli/commit/683697ebc5e129d2b17bded9e4ff318d598e0bd3) | fix | improve route matching for wildcard routes | + + + + + +# 20.3.8 (2025-10-29) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [813cba9b9](https://github.com/angular/angular-cli/commit/813cba9b9bfe60e874595ce25608ca85a890f6bf) | fix | expand jest and jest-environment-jsdom to allow version 30 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [542973ab0](https://github.com/angular/angular-cli/commit/542973ab074ccd9a5f09f73ee7f2706a21db45fc) | fix | add adapters to new reporter | +| [f0885691d](https://github.com/angular/angular-cli/commit/f0885691d18b6575351774fcc50d746d981285f6) | fix | ensure locale data plugin runs before other plugins | +| [45e498f95](https://github.com/angular/angular-cli/commit/45e498f9576ff83eebe02deb235d36498ce06bde) | fix | handle redirects from guards during prerendering | + + + + + +# 19.2.19 (2025-10-29) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [4d8ea27a1](https://github.com/angular/angular-cli/commit/4d8ea27a1726709b8398a26915395e7611571dae) | fix | update vite to v6.4.1 | + + + + + +# 20.3.7 (2025-10-22) + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [a31533cf4](https://github.com/angular/angular-cli/commit/a31533cf492048f62a41b9c09e53779269ee172d) | fix | respect `--force` option when schematic contains `host.create` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [8cdda111c](https://github.com/angular/angular-cli/commit/8cdda111cc0b343aa5eb6a7ccbad93302a543226) | fix | resolve Angular locale data namespace in esbuild | +| [5847ccc54](https://github.com/angular/angular-cli/commit/5847ccc545e54eb77a78b2435db7970faf748156) | fix | update `vite` to `7.11.1` | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------- | +| [3a28fb6a1](https://github.com/angular/angular-cli/commit/3a28fb6a13061215b881c49232db979fc3c2f641) | fix | correctly handle routes with matrix parameters | +| [5db6d6487](https://github.com/angular/angular-cli/commit/5db6d64870c7ce0b883722a07c828946b7d2217d) | fix | ensure server-side navigation triggers a redirect | + + + + + +# 20.3.6 (2025-10-15) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [5271547c8](https://github.com/angular/angular-cli/commit/5271547c80662de10cb3bcb648779a83f6efedfb) | fix | prevent malicious URL from overriding host | + + + + + +# 19.2.18 (2025-10-15) + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [9136a5d13](https://github.com/angular/angular-cli/commit/9136a5d1302bb224ea245460ae29474bd2a3a10b) | fix | prevent malicious URL from overriding host | + + + + + +# 20.3.5 (2025-10-08) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [7f7140680](https://github.com/angular/angular-cli/commit/7f7140680b75ff6b41f7f04349fe10cd928f1a23) | fix | cleanup karma temporary directory after process exit | + + + + + +# 20.3.4 (2025-10-02) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [c94bf7ff0](https://github.com/angular/angular-cli/commit/c94bf7ff0845fe325c39737057ff1ed4ea553011) | fix | Out of the box support for PM2 | +| [465436c9f](https://github.com/angular/angular-cli/commit/465436c9fa21173befe5e39b61afb7f29435c2aa) | fix | use bracket notation for `process.env['pm_id']` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [bc6b63114](https://github.com/angular/angular-cli/commit/bc6b631146c719a337c937e95c7cc5ebca29254b) | fix | mark `InjectionToken` as pure for improved tree-shaking | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [e510ff828](https://github.com/angular/angular-cli/commit/e510ff828f033478d8e1720050a7b3d75d551e16) | fix | mark `InjectionToken` as pure for improved tree-shaking | + + + + + +# 20.3.3 (2025-09-24) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [b7f92da78](https://github.com/angular/angular-cli/commit/b7f92da7835c14b568d07dfb3313802704f28cfd) | fix | add `__screenshots__/` to `.gitignore` | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [a4c9a2007](https://github.com/angular/angular-cli/commit/a4c9a2007ab3e33b2c97fa63f0df8f8662427031) | fix | avoid retaining rendered HTML in memory post-request | + + + + + +# 20.3.2 (2025-09-17) + + + + + +# 19.2.17 (2025-09-17) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------ | +| [365d525b5](https://github.com/angular/angular-cli/commit/365d525b596b437ad0b1a74b1417eaae6aa8694e) | fix | update `vite` to `6.3.6` | + + + + + +# 20.3.1 (2025-09-11) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [be60be499](https://github.com/angular/angular-cli/commit/be60be4997ea0f7be3a4fb993f87b1bd29fc1493) | fix | add timestamp to bundle generation log | +| [d60f4e53d](https://github.com/angular/angular-cli/commit/d60f4e53d8f511d313e517161dc26eb3cc005f1c) | fix | update vite to version `7.1.5` | + + + + + +# 18.2.21 (2025-09-10) + +## Breaking Changes + +### @angular/ssr + +- The server-side bootstrapping process has been changed to eliminate the reliance on a global platform injector. + + Before: + + ```ts + const bootstrap = () => bootstrapApplication(AppComponent, config); + ``` + + After: + + ```ts + const bootstrap = (context: BootstrapContext) => + bootstrapApplication(AppComponent, config, context); + ``` + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------ | +| [700e6bc01](https://github.com/angular/angular-cli/commit/700e6bc0177a3e345a88e31be22496cc3054349b) | fix | avoid extra tick in SSR builds | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [cccc91b91](https://github.com/angular/angular-cli/commit/cccc91b919b4a8365efce9ee691940e351349075) | fix | avoid extra tick in SSR dev-server builds | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [4af385201](https://github.com/angular/angular-cli/commit/4af385201bf8ba05352faec26c6efa866b69d999) | feat | introduce BootstrapContext for isolated server-side rendering | + + + + + +# 19.2.16 (2025-09-10) + +## Breaking Changes + +### @angular/ssr + +- The server-side bootstrapping process has been changed to eliminate the reliance on a global platform injector. + + Before: + + ```ts + const bootstrap = () => bootstrapApplication(AppComponent, config); + ``` + + After: + + ```ts + const bootstrap = (context: BootstrapContext) => + bootstrapApplication(AppComponent, config, context); + ``` + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------ | +| [b0f4330a9](https://github.com/angular/angular-cli/commit/b0f4330a9a2f598b71f12d07e49b6c7c6891febd) | fix | avoid extra tick in SSR builds | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [ee5c5f823](https://github.com/angular/angular-cli/commit/ee5c5f823c87a36c9bcb92db2fc9b4e652dc16c2) | fix | avoid extra tick in SSR dev-server builds | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [32980f7e7](https://github.com/angular/angular-cli/commit/32980f7e7a5821bc9bd311dda6e134970e735722) | feat | introduce BootstrapContext for isolated server-side rendering | + + + + + +# 20.3.0 (2025-09-10) + +## Breaking Changes + +### @angular/ssr + +- The server-side bootstrapping process has been changed to eliminate the reliance on a global platform injector. + + Before: + + ```ts + const bootstrap = () => bootstrapApplication(AppComponent, config); + ``` + + After: + + ```ts + const bootstrap = (context: BootstrapContext) => + bootstrapApplication(AppComponent, config, context); + ``` + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------- | +| [ef20a278d](https://github.com/angular/angular-cli/commit/ef20a278d1455b9cdffc5102b13d0b2206ef1ecb) | fix | align labels in ai-config schema | + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [f6ad41c13](https://github.com/angular/angular-cli/commit/f6ad41c134c7ae938ccda908967e7cc863b3db16) | fix | improve bun lockfile detection and optimize lockfile checks | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------ | +| [1a7890873](https://github.com/angular/angular-cli/commit/1a789087344aa94d061839122e6a63efbfc9c905) | fix | avoid extra tick in SSR builds | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [5d46d6ec1](https://github.com/angular/angular-cli/commit/5d46d6ec114052715a8bd17761a4f258961ad26b) | fix | preserve names in esbuild for improved debugging in dev mode | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [7eacb4187](https://github.com/angular/angular-cli/commit/7eacb41878f5fdac8d40aedfcca6794b77eda5ff) | feat | introduce BootstrapContext for isolated server-side rendering | + + + + + +# 20.2.2 (2025-09-03) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [a793bbc47](https://github.com/angular/angular-cli/commit/a793bbc473dfaddf3fe6ed15805dc4fc84f52865) | fix | don't set a default for array options when length is 0 | +| [2736599e2](https://github.com/angular/angular-cli/commit/2736599e2f6c61032810d8e336c1646db4066392) | fix | set process title when running architect commands | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [5c2abffea](https://github.com/angular/angular-cli/commit/5c2abffea6cf3f672ee256a944dba56dd257665b) | fix | avoid extra tick in SSR dev-server builds | +| [f3c826853](https://github.com/angular/angular-cli/commit/f3c826853501c9cf6d07a1c8ee3363eb79f53005) | fix | maintain media output hashing with vitest unit-testing | + + + + + +# 20.2.1 (2025-08-27) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------- | +| [3b693e09e](https://github.com/angular/angular-cli/commit/3b693e09e8148ef22031aab8f6bc70c928aabc03) | fix | correctly set default array values | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------ | +| [6937123a3](https://github.com/angular/angular-cli/commit/6937123a393e2ba9221962b0174056c14437a988) | fix | directly resolve karma config template in migration | +| [5d6dd4425](https://github.com/angular/angular-cli/commit/5d6dd44259a0d89098c2a0c784e726b43ce32316) | fix | prevent AI config schematic from failing when 'none' and other AI tools are selected | + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------- | +| [e93919dea](https://github.com/angular/angular-cli/commit/e93919dea7df55a3aac2fa5c93c4560c50a2d749) | fix | correctly set default array values | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [06a6ddc10](https://github.com/angular/angular-cli/commit/06a6ddc102f5dc9018ec982f6e4cf56259cc4b52) | fix | correct JS/TS file paths when running under Bazel | +| [b6816b0cb](https://github.com/angular/angular-cli/commit/b6816b0cbaf1262d7015b9d7f7fb425f53995947) | fix | ensure karma polyfills reporter factory returns a value | + + + + + +# 20.2.0 (2025-08-20) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [b4de9a1bf](https://github.com/angular/angular-cli/commit/b4de9a1bf50a35404fb79eb3f120faafd0ce825a) | feat | add --experimental-tool option to mcp command | +| [755ba70fd](https://github.com/angular/angular-cli/commit/755ba70fd7ef38793d15797ba402020c375c3295) | feat | add --local-only option to mcp command | +| [59d7ef343](https://github.com/angular/angular-cli/commit/59d7ef343b6f1feea37a019935578c560d3d5e41) | feat | add --read-only option to mcp command | +| [4e92eb6f1](https://github.com/angular/angular-cli/commit/4e92eb6f17cb30259bc8e8d1979bbd9989bc5ad0) | feat | add modernize tool to the MCP server | +| [a3b25f675](https://github.com/angular/angular-cli/commit/a3b25f675283fdd8cc5689e3ec88f27aa1386390) | fix | add choices to command line parser when type is array and has an enum | +| [e19eee614](https://github.com/angular/angular-cli/commit/e19eee61404a9ca6268ebbc69f671a422d81df9b) | fix | address Node.js deprecation DEP0190 | +| [4ee6f327a](https://github.com/angular/angular-cli/commit/4ee6f327a206f8ff2ad5eeab43193df56b92b5e0) | fix | apply default to array types | +| [8ba6b0bcc](https://github.com/angular/angular-cli/commit/8ba6b0bcc8c8087875d14a0aefc6b7b52f39ce2a) | fix | use correct path for MCP get_best_practices tool | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [2e3cfd598](https://github.com/angular/angular-cli/commit/2e3cfd598c9366d0036a52cd18024317b33e6fca) | feat | add migration to remove default Karma configurations | +| [d80dae276](https://github.com/angular/angular-cli/commit/d80dae276e9554c13e0c37640d0db8acafc9d48b) | feat | add schematics to generate ai context files. | +| [ffe6fb916](https://github.com/angular/angular-cli/commit/ffe6fb916d496da1c6c20942f6e6b05a679b0f7d) | fix | allow AI config prompt to be skipped without selecting a value | +| [ae2802b7d](https://github.com/angular/angular-cli/commit/ae2802b7db358c5a3f0590feea212a768a710353) | fix | improve AI config prompt wording | +| [b017f84fd](https://github.com/angular/angular-cli/commit/b017f84fdaf36bc0fcad2241846665c73b52b6d8) | fix | improve coverage directory handling for Karma configuration comparisons | +| [6a79f9a75](https://github.com/angular/angular-cli/commit/6a79f9a75cdcbb0761c4044066748f4eb788a57f) | fix | zoneless is now stable | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------- | +| [c43504d8d](https://github.com/angular/angular-cli/commit/c43504d8d96a4436ce71c23d957aec2d080106b8) | fix | address Node.js deprecation DEP0190 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [fb06bb505](https://github.com/angular/angular-cli/commit/fb06bb5050e92eb4d0f95d7774552d0902163f6a) | feat | add headless mode for vitest browser mode | + + + + + +# 20.1.6 (2025-08-13) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [584bc1d41](https://github.com/angular/angular-cli/commit/584bc1d4173e7f129aa20e829f1dfb03e1e0dc9e) | fix | add extra prettier config | +| [02b0506fd](https://github.com/angular/angular-cli/commit/02b0506fde638b89510e5a78b3d190ba60a8d6ba) | fix | correct configure the `typeSeparator` in the library schematic | + + + + + +# 20.1.5 (2025-08-06) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [48ca04474](https://github.com/angular/angular-cli/commit/48ca044745f49bc7fc365a621827294f4cc82c50) | fix | cache MCP best practices content and add tool annotations | + + + + + +# 20.1.4 (2025-07-30) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [2d753cc62](https://github.com/angular/angular-cli/commit/2d753cc62c9a801c40923a43e4af5f74b22700e0) | fix | skip workspace-specific tools when outside a workspace | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------- | +| [42d72ef4d](https://github.com/angular/angular-cli/commit/42d72ef4d99380dbb1c0e03e3e3abfb2223fa539) | fix | skip vite transformation of CSS-like assets | + + + + + +# 20.1.3 (2025-07-24) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------ | +| [ea5cd0e81](https://github.com/angular/angular-cli/commit/ea5cd0e81196467ea66f50c106cffec1cd8a1a56) | fix | update `vite` to `7.0.6` | + + + + + +# 20.1.2 (2025-07-23) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [96785224f](https://github.com/angular/angular-cli/commit/96785224f55291cd60553aead07ead10d9d2fbda) | fix | `define` option is being included multiple times in the JSON help | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [0d0040bdf](https://github.com/angular/angular-cli/commit/0d0040bdf58a82e18f7669363b6f149313524bfc) | fix | use crypto.randomUUID instead of Date.now for unique string in tmp file names | + + + + + +# 20.1.1 (2025-07-16) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------ | +| [541b33f8d](https://github.com/angular/angular-cli/commit/541b33f8d977c1fe8f609099a8b8ed1c5f8e827e) | fix | emit a warning when `outputHashing` is set to `all` or `bundles` when HMR is enabled | +| [558a0fe92](https://github.com/angular/angular-cli/commit/558a0fe9275e68e0b768de3ee2e5bee0d6d84a6e) | fix | normalize code coverage include paths to POSIX | + + + + + +# 20.1.0 (2025-07-09) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [dc45c186e](https://github.com/angular/angular-cli/commit/dc45c186ec16e345b75ffcd57961a8e0cfd4b649) | feat | add initial MCP server implementation | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------- | +| [1c19e0dcd](https://github.com/angular/angular-cli/commit/1c19e0dcd4a87fbf542201e09a402a8fccdfcd88) | feat | use signal in app component | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [49a09737d](https://github.com/angular/angular-cli/commit/49a09737d5412c302d09b40de198251bb99789d1) | feat | provide partial custom postcss configuration support | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [1159cf081](https://github.com/angular/angular-cli/commit/1159cf08103081d2b851e59bc1c5fb200f114982) | feat | add code coverage reporters option for unit-test | +| [8f305ef0b](https://github.com/angular/angular-cli/commit/8f305ef0ba91ec9bf6417b7084965205cf5488e7) | feat | add dataurl, base64 loaders | +| [adfeee0a4](https://github.com/angular/angular-cli/commit/adfeee0a4c95a03d430054eeecd4cca1bdb0efeb) | fix | adjust coverage includes/excludes for unit-test vitest runner | +| [c19cd2985](https://github.com/angular/angular-cli/commit/c19cd2985cbf1ea8c1c15f020bc530d6768cb0fa) | fix | coverage reporter option | +| [8879716ca](https://github.com/angular/angular-cli/commit/8879716cac9b2134db2795b1810595ea56e9d421) | fix | expose unit test and karma builder API | +| [a415a4999](https://github.com/angular/angular-cli/commit/a415a4999f337f5bc3c0ee626aaba58b6c5ad4e1) | fix | improve default coverage reporter handling for vitest | +| [e0de8680d](https://github.com/angular/angular-cli/commit/e0de8680d1ea25aa71024d7b89beaa1e75889c47) | fix | inject zone.js/testing before karma builder execution | +| [2672f6ec1](https://github.com/angular/angular-cli/commit/2672f6ec17de6e05b19acda0e0b09a6715c9f83f) | fix | json and json-summary as vitest coverage reporters | +| [b67fdfd6b](https://github.com/angular/angular-cli/commit/b67fdfd6bc422bd6a46db923470579c760c5ec27) | fix | resolve "Controller is already closed" error in Karma | +| [2784883ec](https://github.com/angular/angular-cli/commit/2784883ecfb63e4aa6a6c69fd10e457316b4958c) | fix | support extra test setup files with unit-test vitest runner | +| [f177f5508](https://github.com/angular/angular-cli/commit/f177f5508adb23f604d9abb5f4a33f3af5f32561) | fix | support injecting global styles into vitest unit-tests | +| [130c65014](https://github.com/angular/angular-cli/commit/130c650146595f237bc3285302d0075ba0387546) | fix | use an empty array as default value for vitest exclude | +| [917af12ae](https://github.com/angular/angular-cli/commit/917af12aeb82b1437e7b43a03ae80b58a09f0224) | fix | use date/time based output path for vitest unit-test | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [21b5852f1](https://github.com/angular/angular-cli/commit/21b5852f120dd42ea4ae9fce043e04ec61da16dd) | fix | ensure `loadChildren` runs in correct injection context during route extraction | + + + + + +# 20.0.6 (2025-07-09) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [5542445d3](https://github.com/angular/angular-cli/commit/5542445d30685a2ebbf66d15848a5abc657863c8) | fix | remove constructor from service template | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [0836ad28f](https://github.com/angular/angular-cli/commit/0836ad28f8e4702f8ba12beef615d60c01a7947c) | fix | correctly remap Angular diagnostics | +| [c475e546b](https://github.com/angular/angular-cli/commit/c475e546bfdfee0c098e5198325b52a53882d034) | fix | exclude `@vitest/browser/context` from esbuild bundling | +| [1a2da161e](https://github.com/angular/angular-cli/commit/1a2da161e73f4f1fe876329adf8ed89f9044b404) | fix | failed to proxy error for assets | + + + + + +# 20.0.5 (2025-07-01) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [1ebd53df7](https://github.com/angular/angular-cli/commit/1ebd53df7168307f699a9f9ae8f5ef5b9bcf352c) | fix | remove unused `@vitejs/plugin-basic-ssl` dependency | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------- | +| [05cebdbcd](https://github.com/angular/angular-cli/commit/05cebdbcd1466bf5c95eb724a784aeb8c7ac083f) | fix | proxy karma request from `/` to `/base` | + + + + + +# 20.0.4 (2025-06-25) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------- | +| [2316fe29d](https://github.com/angular/angular-cli/commit/2316fe29de57c593e5ccb8be612d3918b60d9761) | fix | add missing prettier config | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [309742289](https://github.com/angular/angular-cli/commit/30974228988d7ff96741fe0515c35275e8a6bc0a) | fix | avoid preloading unnecessary dynamic bundles | +| [82691b98f](https://github.com/angular/angular-cli/commit/82691b98fa458febf40a16beb91b24c4b6c519c9) | fix | ensure correct referer header handling in web request conversion | + + + + + +# 20.0.3 (2025-06-18) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [e90a808c0](https://github.com/angular/angular-cli/commit/e90a808c0100beb319bae36ca3b771ee2da89435) | fix | include `main.server.ts` in `tsconfig.files` when present | +| [5c48b8e0a](https://github.com/angular/angular-cli/commit/5c48b8e0ac38a108740ebb290dc1e666ce390806) | fix | reset module `typeSeparator` when generating applications | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [56f426e25](https://github.com/angular/angular-cli/commit/56f426e2548b86c00d4da19b9f7b5cf97dc79104) | fix | include custom bundle name scripts with karma | +| [dfe3a8b73](https://github.com/angular/angular-cli/commit/dfe3a8b7342dd492e42ec48052612255ba76c09b) | fix | increase worker idle timeout | +| [e6d27bd5e](https://github.com/angular/angular-cli/commit/e6d27bd5e3fe64f597621e0d5c08060cea64a302) | fix | set scripts option output as classic script for karma | + + + + + +# 20.0.2 (2025-06-11) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [bf64a0f2d](https://github.com/angular/angular-cli/commit/bf64a0f2dcc2cbd5dc56e575dd337c16f2a3342b) | fix | add `less` as a devDependency when selected as the style preprocessor | +| [cb258a3e1](https://github.com/angular/angular-cli/commit/cb258a3e1525cda985109692fb88449259119ff2) | fix | correctly detect modules using new file extension format | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [424f1cbbf](https://github.com/angular/angular-cli/commit/424f1cbbfb709b4d6f480e6321ec1a152813cf5c) | fix | do not consider internal Angular files as external imports | + + + + + +# 19.2.15 (2025-06-11) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [b120e1411](https://github.com/angular/angular-cli/commit/b120e1411c28c99defb34274a11f0fb54972178a) | fix | update dependency webpack-dev-server to v5.2.2 | + + + + + +# 18.2.20 (2025-06-11) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| ------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [f048078](https://github.com/angular/angular-cli/commit/f048078ab6012b5da4dff024c107f42f79693682) | fix | update dependency webpack-dev-server to v5.2.2 | + + + + + +# 20.0.1 (2025-06-04) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [0883248cb](https://github.com/angular/angular-cli/commit/0883248cbdebcad09393349a0a5d9487b2a452ae) | fix | improve Node.js version check and error messages | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------- | +| [525ddcbd2](https://github.com/angular/angular-cli/commit/525ddcbd290525e4dac2547c352cf6c774d728a2) | fix | only overwrite JSON file if actually changed | +| [83c820e5a](https://github.com/angular/angular-cli/commit/83c820e5ab55d01662417a51e4cc8d094e409fc6) | fix | remove karma config devkit package usages during application migration | +| [87266b38a](https://github.com/angular/angular-cli/commit/87266b38a09ce783ac6d18f532ebe1f8ae5954c0) | fix | skip zone.js dependency for zoneless applications | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [e5efdc577](https://github.com/angular/angular-cli/commit/e5efdc577be913870b29173345b8194b87420474) | fix | also disable outputMode in vitest unit-tests | +| [5814393db](https://github.com/angular/angular-cli/commit/5814393dbb2f9227ce10f1df77a8deee06c7d1c5) | fix | resolve junit karma reporter output to workspace root | + + + + + +# 20.0.0 (2025-05-28) + +## Breaking Changes + +### @angular/cli + +- Node.js v18 is no longer supported with Angular. + + Before updating a project to Angular v20, the Node.js version must be + at least 20.11.1. For the full list of supported Node.js versions, + see https://angular.dev/reference/versions. + +- Node.js versions from 22.0 to 22.10 are no longer supported + +### @schematics/angular + +- `--server-routing` option has been removed from several schematics. Server routing will be used when using the application builder. + +### @angular-devkit/schematics + +- The `NodePackageLinkTask` has been removed without a replacement. Create a custom task if needed. + + Note: This does not affect application developers. + +### @angular/build + +- TypeScript versions less than 5.8 are no longer supported. + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------- | +| [e03f2b899](https://github.com/angular/angular-cli/commit/e03f2b89992cb1e34a57f9cd5beef77674c116b6) | feat | Add global error listeners to new app generation | +| [1e137ca84](https://github.com/angular/angular-cli/commit/1e137ca848839402bc214fbccdc04243862d01d0) | feat | add migration to update `moduleResolution` to `bundler` | +| [26fd4ea73](https://github.com/angular/angular-cli/commit/26fd4ea73ad2a0148ae587d582134c68a0bf4b86) | feat | add migrations for server rendering updates | +| [5876577af](https://github.com/angular/angular-cli/commit/5876577af163b534846e720b0184558197dce741) | feat | Add prompt for new apps to be zoneless | +| [fdc6291dd](https://github.com/angular/angular-cli/commit/fdc6291dda4903f418667d415b05367390cf829d) | feat | add update migration to keep previous style guide generation behavior | +| [093c5a315](https://github.com/angular/angular-cli/commit/093c5a3152c4282d4afb51df40945283cc94d281) | feat | directly use `@angular/build` in new projects | +| [d6f594fe0](https://github.com/angular/angular-cli/commit/d6f594fe0f8f21d9c0e2abedb5c8433a1aa5c157) | feat | generate applications using TypeScript project references | +| [0ab1ddf63](https://github.com/angular/angular-cli/commit/0ab1ddf632b7305db28a2f87f5c6b099a44669f6) | feat | generate libraries using TypeScript project references | +| [18e13e2ce](https://github.com/angular/angular-cli/commit/18e13e2ceed931d29aa5582980c7d6d1f66c9787) | feat | remove `--server-routing` option | +| [03180fe03](https://github.com/angular/angular-cli/commit/03180fe0358662f8fd3255ad546994da3e3bda9c) | feat | use TypeScript module preserve option for new projects | +| [86d241629](https://github.com/angular/angular-cli/commit/86d241629ff51f0bb5988e81cac8658b01704d49) | fix | add `@angular/ssr` dependency only when `provideServerRendering` import has been updated | +| [9e6b9b537](https://github.com/angular/angular-cli/commit/9e6b9b5379d0448578b3bfb6100852dea7febe75) | fix | add type checking of host bindings to strict config | +| [8654b3fea](https://github.com/angular/angular-cli/commit/8654b3fea4e2ba5af651e6c2a4afddaf6fc42802) | fix | application migration should migrate karma builder package | +| [c557a19ef](https://github.com/angular/angular-cli/commit/c557a19ef4eed9f2d805bb235d3819c69a1aaef6) | fix | avoid empty polyfill option for new zoneless application | +| [90615a88b](https://github.com/angular/angular-cli/commit/90615a88b10535d7f0197008b9d48ceac4409c23) | fix | default component templates to not use `.ng.html` extension | +| [672ae14cd](https://github.com/angular/angular-cli/commit/672ae14cd21d02a3b4727e2febd88747b9e4c684) | fix | drop composite in tsconfig | +| [da6ef626f](https://github.com/angular/angular-cli/commit/da6ef626f960b187a7862f0caa3d8aed38224ac2) | fix | ensure app-shell schematic consistently uses `withAppShell` | +| [f126f8d34](https://github.com/angular/angular-cli/commit/f126f8d34b087dd3a916dfb93cd255aac4d6c309) | fix | ensure module discovery checks for an NgModule decorator | +| [dc2f65999](https://github.com/angular/angular-cli/commit/dc2f65999a64453a26b61c96080b732fdc4147c8) | fix | generate component templates with a `.ng.html` file extension | +| [23fc8e1e1](https://github.com/angular/angular-cli/commit/23fc8e1e176f23442876b086bff52dd5f35abbc0) | fix | generate components without a `.component` extension/type | +| [8d715fa94](https://github.com/angular/angular-cli/commit/8d715fa948d432b18d06bcf42eed3a7681383523) | fix | generate directives without a .directive extension/type | +| [5fc595144](https://github.com/angular/angular-cli/commit/5fc5951440c9306c4349fa3f8dbcb1b584441fe8) | fix | generate guards with a dash type separator | +| [040282d8f](https://github.com/angular/angular-cli/commit/040282d8fd5838266785997442c4f5a269666cf3) | fix | generate interceptors with a dash type separator | +| [070d60fb3](https://github.com/angular/angular-cli/commit/070d60fb383bb14d39f969942641253e54980fcf) | fix | generate modules with a dash type separator | +| [e6083b57b](https://github.com/angular/angular-cli/commit/e6083b57bb5b38db14264253095a9729738d22f2) | fix | generate pipes with a dash type separator | +| [92e193c0b](https://github.com/angular/angular-cli/commit/92e193c0b9a2b85b68d83c5f378d30fc8d10f13e) | fix | generate resolvers with a dash type separator | +| [bc0f07b48](https://github.com/angular/angular-cli/commit/bc0f07b484300848ee81c5719c58909b40f99deb) | fix | generate services without a .service extension/type | +| [ea1143ddd](https://github.com/angular/angular-cli/commit/ea1143ddd801b775828f0b62788f4cce0dd7e9ce) | fix | infer app component name and path in server schematic | +| [bcc0892a6](https://github.com/angular/angular-cli/commit/bcc0892a65f00e68709e84c380f448a5e0fd05e7) | fix | migrate `provideServerRoutesConfig` to `provideServerRendering` | +| [5e8c6494d](https://github.com/angular/angular-cli/commit/5e8c6494d3eb5a0f61e8b07de4c53233147e9d46) | fix | relative tsconfig paths in references | +| [381d35fe4](https://github.com/angular/angular-cli/commit/381d35fe40f062713eac550a12b58c30c1ec33a9) | fix | remove empty `scripts` option value from new applications | +| [148498c2b](https://github.com/angular/angular-cli/commit/148498c2bcd0feb495dc0aa14b6a4555ac01facb) | fix | Remove experimental from zoneless | +| [a910fe9ae](https://github.com/angular/angular-cli/commit/a910fe9ae0423146f6509c5b9c45c88415365c9f) | fix | remove explicit `outputPath` option value from generated applications | +| [901ab60d9](https://github.com/angular/angular-cli/commit/901ab60d9f63fcff17213dbf7fe17e4a46835974) | fix | remove explicit index option from new applications | +| [be6f13ec1](https://github.com/angular/angular-cli/commit/be6f13ec16f01851d38b900dbfc4df7ccfb94d16) | fix | remove setting files tsconfig field with SSR/Server generation | +| [661609e3e](https://github.com/angular/angular-cli/commit/661609e3e583198828baf236338db17b6222f4d8) | fix | set explicit type in library schematic | +| [0f7dc2cd8](https://github.com/angular/angular-cli/commit/0f7dc2cd8f76f928e64e734563a433ff6a0d478c) | fix | skip spec project reference for minimal ng new | +| [3cf6ab0f7](https://github.com/angular/angular-cli/commit/3cf6ab0f77b23b8717e79b7125ea930cb018ebc5) | fix | support using default browser option when not present | +| [b13805a77](https://github.com/angular/angular-cli/commit/b13805a77a5654a352a6c6f760965c326977ff14) | fix | use protected for class member | + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [e513cd4aa](https://github.com/angular/angular-cli/commit/e513cd4aa218e5ab634f05c18b6aa90f223e096c) | fix | add Node.js 24 as supported version | +| [5e90c1b4e](https://github.com/angular/angular-cli/commit/5e90c1b4ec3f1d05ad00f2f854347a5bf8cb0860) | fix | remove Node.js v18 support | +| [787e510dc](https://github.com/angular/angular-cli/commit/787e510dccabf30589194fcefdb74a687dfa3945) | fix | update min Node.js support to 20.19, 22.12, and 24.0 | +| [64732534e](https://github.com/angular/angular-cli/commit/64732534ecb84d702bde2469466a05e765879f9a) | fix | update minimum supported Node.js 22 version to 22.11.0 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [a42e045ba](https://github.com/angular/angular-cli/commit/a42e045bab3bfbeb0bb69c3406fd0a76ae263cdf) | fix | respect i18nDuplicateTranslation option when duplicates exist | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------- | +| [e6be37601](https://github.com/angular/angular-cli/commit/e6be37601d57f884a1ddf2cc1ddecf51819b9f51) | refactor | remove deprecated `NodePackageLinkTask` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------- | +| [d6ea6b09f](https://github.com/angular/angular-cli/commit/d6ea6b09f182433f859a78d4a4d38a9db521e593) | feat | add experimental vitest browser support to unit-testing | +| [12def3a2e](https://github.com/angular/angular-cli/commit/12def3a2e907ca8e7d530cea1b39bba90e153144) | feat | add experimental vitest unit-testing support | +| [c1de63300](https://github.com/angular/angular-cli/commit/c1de633007c423cfd9113cc781b5647e59306146) | feat | allow control of source map sources content for application builds | +| [31c81e9c6](https://github.com/angular/angular-cli/commit/31c81e9c6859e68d00828b345d996d1aff431b25) | feat | drop support for TypeScript older than 5.8 | +| [e80963036](https://github.com/angular/angular-cli/commit/e8096303659f4f02ac05fe8f655bb29bc12fda28) | feat | expand browser support policy to widely available Baseline | +| [3c9172159](https://github.com/angular/angular-cli/commit/3c9172159c72f3c8ea116557ba5bf917a15d2f07) | feat | integrate Chrome automatic workspace folders | +| [9b682e625](https://github.com/angular/angular-cli/commit/9b682e62519e761477e6266650239bf58026a9f4) | feat | support a default outputPath option for applications | +| [d067cedf0](https://github.com/angular/angular-cli/commit/d067cedf05051e3a0f237d50306e1e4c881a0328) | feat | support custom resolution conditions with applications | +| [f4be83119](https://github.com/angular/angular-cli/commit/f4be831197010a17394264bc74b1eb385ba95028) | feat | Support Sass package importers | +| [f36a27272](https://github.com/angular/angular-cli/commit/f36a27272f3f7e2673d692d73286280f4c6d357a) | fix | allow a default application `browser` option | +| [f42f5c14c](https://github.com/angular/angular-cli/commit/f42f5c14c0c51d7705bee7b67afc317c45fb9230) | fix | allow component HMR for templates with i18n | +| [e36bf964a](https://github.com/angular/angular-cli/commit/e36bf964a776b04f6a9193387692274865e1630b) | fix | allow TestBed provider configuration with vitest unit-testing | +| [769961e4a](https://github.com/angular/angular-cli/commit/769961e4a9a67f88f8fb4b7de80dea67825219f9) | fix | allow vitest-based unit testing to use watch option | +| [3e24a59a9](https://github.com/angular/angular-cli/commit/3e24a59a9db9f11a80fa616c68be4380c4816ed5) | fix | disable TypeScript `composite` option with Angular compiler | +| [b155ba1dc](https://github.com/angular/angular-cli/commit/b155ba1dcdbc3c506311e4434c37f1b4c77c7572) | fix | enable unit-test builder watch outside CI | +| [7bb1f8747](https://github.com/angular/angular-cli/commit/7bb1f87478d441e35b73b920c8bfcd4376a3422d) | fix | enable unit-test reporters option | +| [05485ede7](https://github.com/angular/angular-cli/commit/05485ede7b472f98120c51f28bd485eeb635bac2) | fix | ensure `com.chrome.devtools.json` is consistently served after initial run | +| [7877d9a97](https://github.com/angular/angular-cli/commit/7877d9a971dbef5025fdb9a40f49f36e9b42569d) | fix | ensure disabled vitest config loading | +| [c8c73185a](https://github.com/angular/angular-cli/commit/c8c73185a66c7c7825e30f7fcedbaacc9ca1c593) | fix | ensure matching coverage excludes with karma on Windows | +| [aec95042b](https://github.com/angular/angular-cli/commit/aec95042b4d690c25645af590788c608b4b353dc) | fix | exclude only source test files with unit-test vitest support | +| [5bea3de4c](https://github.com/angular/angular-cli/commit/5bea3de4cb2ffa26ad04aced22be3ff11f519f92) | fix | invalidate `com.chrome.devtools.json` if project is moved | +| [1cd65a08d](https://github.com/angular/angular-cli/commit/1cd65a08d5278134115f33ff0e666aee420faf8a) | fix | perform testing module cleanup when using Vitest | +| [c51a540ce](https://github.com/angular/angular-cli/commit/c51a540ce4fb191811d2be06a9937f11826b38a5) | fix | provide direct debugging support for unit test builder | +| [c7f2cb596](https://github.com/angular/angular-cli/commit/c7f2cb59684a264bb4ecab2024d8a8c58efbefa7) | fix | provide vitest globals in unit-test builder | +| [d2bfc6bd4](https://github.com/angular/angular-cli/commit/d2bfc6bd4eb0892e9eb6205838158142b716d21c) | fix | revert setup unit-test polyfills before TestBed init | +| [0d40cdecd](https://github.com/angular/angular-cli/commit/0d40cdecd0fdc1b03d2cafcdd5321db0d31b56ee) | fix | setup unit-test polyfills before TestBed init | +| [fa3dc6387](https://github.com/angular/angular-cli/commit/fa3dc6387db971be265c1c5391c71a23c62df15c) | fix | show unit-test error for missing vitest browser package | +| [247cd3352](https://github.com/angular/angular-cli/commit/247cd335217d9997995321b4b235c40480adadb3) | fix | show unit-test error for missing vitest package | +| [eee816f79](https://github.com/angular/angular-cli/commit/eee816f79b4464286dcecc16f53c06be8afd4ccf) | fix | use global unit-test hooks during TestBed init | +| [566de64cb](https://github.com/angular/angular-cli/commit/566de64cbeebeb532db3c0f4ed1dd607c31dedf1) | fix | use virtual module for Karma TestBed initialization | +| [52fbffcd7](https://github.com/angular/angular-cli/commit/52fbffcd7bb129720a10e6bf865e4e3a01f939d6) | fix | warn and remove jsdom launcher when used with karma | +| [5ff6188c4](https://github.com/angular/angular-cli/commit/5ff6188c4330b009201a64a23d0090bfcec0612f) | perf | directly check code for Angular partial linking | + +### @angular/pwa + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [fa0a06f9f](https://github.com/angular/angular-cli/commit/fa0a06f9f92b28929fc775074245a0b97c3d9adc) | fix | support using default index option when not present | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------- | +| [33b9de3eb](https://github.com/angular/angular-cli/commit/33b9de3eb1fa596a4d5a975d05275739f2f7b8ae) | feat | expose `provideServerRendering` and remove `provideServerRouting` | +| [cdfc50c29](https://github.com/angular/angular-cli/commit/cdfc50c29a2aa6f32d172b505a0ef09e563dfc59) | feat | stabilize `AngularNodeAppEngine`, `AngularAppEngine`, and `provideServerRouting` APIs | +| [319b8e0c2](https://github.com/angular/angular-cli/commit/319b8e0c2a0cd30ab96576464b4172a1f76a97a6) | fix | manage unhandled errors in zoneless applications | +| [2d11e8e45](https://github.com/angular/angular-cli/commit/2d11e8e45b29cf879ee72ffbcf438198d73ffaba) | fix | return 302 when redirectTo is a function | +| [059c10eb4](https://github.com/angular/angular-cli/commit/059c10eb4df72b0d67f73783826e2bbae611ad35) | fix | SSR should work without `@angular/router` | +| [63428f3f1](https://github.com/angular/angular-cli/commit/63428f3f1e2ffd427011ea8a17b70f8829ae0bdf) | perf | flush headers prior to start rendering the HTML | +| [280693231](https://github.com/angular/angular-cli/commit/280693231e143aa09f841e3179317573a3576545) | perf | optimize response times by introducing header flushing | +| [6bd7b9b4a](https://github.com/angular/angular-cli/commit/6bd7b9b4a59240caa4f19185570aec8263d8a0a7) | perf | optimized request handling performance | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [427bd846f](https://github.com/angular/angular-cli/commit/427bd846f552b393cb969472a05488ac11d47e9f) | fix | disable TypeScript composite option with Angular compiler | + + + + + +# 19.2.14 (2025-05-28) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [a3504fd45](https://github.com/angular/angular-cli/commit/a3504fd45602ec73ce1781e46e6c92b6042a51da) | fix | HMR requires AOT do not show HMR enabled when using JIT | +| [5ce9f96a4](https://github.com/angular/angular-cli/commit/5ce9f96a4efeb4efabe3c161ab596d049a1edd97) | fix | include full metadata for AOT unit-testing | + + + + + +# 19.2.13 (2025-05-21) + +### @angular/pwa + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [ad2fb2959](https://github.com/angular/angular-cli/commit/ad2fb29597e22767618d046fef3fb54bf8e95b5d) | fix | remove `background_color` and `theme_color` from manifest | + + + + + +# 19.2.12 (2025-05-14) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [0098c38c6](https://github.com/angular/angular-cli/commit/0098c38c6d77310effa8c647e1bbfb32fb92afc5) | fix | properly handle Node.js `require()` errors with ESM modules | + + + + + +# 19.2.11 (2025-05-07) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [9eaf34405](https://github.com/angular/angular-cli/commit/9eaf344056b8772b623b0bfc27a66ad985941ae6) | fix | correctly set i18n subPath in webpack browser builder | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [cba66a85c](https://github.com/angular/angular-cli/commit/cba66a85c0bb26813d320281072495473a2d14e3) | fix | avoid attempting to watch bundler internal files | +| [009fc3776](https://github.com/angular/angular-cli/commit/009fc377636817a4dc178908245695d5cff29e75) | fix | avoid internal karma request cache for assets | +| [b43da3949](https://github.com/angular/angular-cli/commit/b43da39499ca477a896f7f957debb05ceed1372a) | perf | fix unnecessary esbuild rebuilds | + + + + + +# 19.2.10 (2025-04-30) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------- | +| [067f1cba0](https://github.com/angular/angular-cli/commit/067f1cba031361f71c79b70af143c53c777e9f7d) | fix | update vite to 6.2.7 | + + + + + +# 17.3.17 (2025-04-30) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [743d85bac](https://github.com/angular/angular-cli/commit/743d85bacce03bcc454574e0ffa9f243ff6631dd) | fix | update http-proxy-middleware to v2.0.8 | + + + + + +# 19.2.9 (2025-04-23) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [de52cc2c8](https://github.com/angular/angular-cli/commit/de52cc2c813e49a06828ff9e9ef0543fa63a9929) | fix | update http-proxy-middleware to v3.0.5 | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [cc5229a45](https://github.com/angular/angular-cli/commit/cc5229a4507848d4d2bcf7409ffa56a7c4b2a136) | fix | pass `preserveSymlinks` option to Karma esbuild builder | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [a4e415ea6](https://github.com/angular/angular-cli/commit/a4e415ea6ab204b6d5f5974c6f0a073d66c40faf) | fix | support `getPrerenderParams` for wildcard routes | + + + + + +# 18.2.19 (2025-04-23) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [01cc617bc](https://github.com/angular/angular-cli/commit/01cc617bc0e0a5a30c3b86f679494500a914c574) | fix | update http-proxy-middleware to v3.0.5 | + + + + + +# 19.2.8 (2025-04-16) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [4a8a4a083](https://github.com/angular/angular-cli/commit/4a8a4a0837af6a095a1e4ad6ae07436073324a7a) | fix | include `module` value check when adding custom conditions | +| [00cd0d123](https://github.com/angular/angular-cli/commit/00cd0d1235ed13781689ae4c4636371dab46b493) | fix | prevent nested CSS in components | +| [a297c4153](https://github.com/angular/angular-cli/commit/a297c4153fd72581cbcf8136c9524c415c561f53) | fix | properly resolve transitive external dependencies in vite-dev-server | +| [8ab033e8e](https://github.com/angular/angular-cli/commit/8ab033e8e56d26c75d8871f81291e702b8985adc) | fix | update vite to 6.2.6 | + + + + + +# 19.2.7 (2025-04-09) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [7f1e8c677](https://github.com/angular/angular-cli/commit/7f1e8c6777dbf60e2a3864774a8c4140bb76f640) | fix | include component test metadata in development builds | +| [74cd4edd5](https://github.com/angular/angular-cli/commit/74cd4edd5bbf5ae03a910be036f6e7fa7db35642) | fix | skip normalization of relative externals | + + + + + +# 18.2.18 (2025-04-09) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [4245ca7b4](https://github.com/angular/angular-cli/commit/4245ca7b434e0aa859c805c459ce50238601b940) | fix | update vite to 5.4.17 | + + + + + +# 17.3.16 (2025-04-09) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------- | +| [5aa53b40c](https://github.com/angular/angular-cli/commit/5aa53b40c34e1555548d201f840a5ffc01f14601) | fix | remove undici from dependencies | +| [fce61564d](https://github.com/angular/angular-cli/commit/fce61564ded8c476ef1c257d2844b1a1560af732) | fix | update vite to 5.4.17 | + + + + + +# 19.2.6 (2025-04-02) + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------- | +| [e5aec562f](https://github.com/angular/angular-cli/commit/e5aec562feb0d293e88d560ea4ec0720e90dbc11) | fix | properly resolve relative schematics when executed from a nested directory | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [76cfd364a](https://github.com/angular/angular-cli/commit/76cfd364a8b398153c09ce29c5672272ac0bce23) | fix | correctly handle `false` value in server option | +| [d69188c6b](https://github.com/angular/angular-cli/commit/d69188c6be2b851e3dfb84e2bd8d209062d7a283) | fix | update vite to 6.2.4 due to a security issues | + + + + + +# 18.2.17 (2025-04-02) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [247ceff7f](https://github.com/angular/angular-cli/commit/247ceff7f7d71901f51dbab1c1a5235d59e45847) | fix | update vite to 5.4.16 due to a security issues | + + + + + +# 17.3.15 (2025-04-02) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [0525fec61](https://github.com/angular/angular-cli/commit/0525fec6183c2972b97a6ad4d57e89aaa478a2de) | fix | update vite to 5.4.16 due to a security issues | + + + + + +# 19.2.5 (2025-03-26) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [20455e2a6](https://github.com/angular/angular-cli/commit/20455e2a64558fcbb11906cb414a99d3976645d6) | fix | correct handling of response/request errors | +| [32b1dcd91](https://github.com/angular/angular-cli/commit/32b1dcd91b9f351bb6baa54f52c81c465185e01b) | fix | handle undefined `getOrCreateAngularServerApp` during error compilation | +| [7552a9fec](https://github.com/angular/angular-cli/commit/7552a9fec971f64ff27d78754ed13654e9a56b43) | fix | normalize karma asset paths before lookup | +| [1eb5b4357](https://github.com/angular/angular-cli/commit/1eb5b43575ab9908122606b94c0aaa53718678aa) | fix | update vite to 6.2.3 | + + + + + +# 18.2.16 (2025-03-26) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [4267a80c5](https://github.com/angular/angular-cli/commit/4267a80c5cd1e9e6aaae0f9090e21c2d71a6887f) | fix | remove `@vitejs/plugin-basic-ssl` from dependencies | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [9c2904d0d](https://github.com/angular/angular-cli/commit/9c2904d0d3a7b2790b27d21c1ff23e6d8a01c4f0) | fix | update vite to 5.4.15 | + + + + + +# 17.3.14 (2025-03-26) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [cb8f859f1](https://github.com/angular/angular-cli/commit/cb8f859f181a325c15b91791c78f5326f22bb7f5) | fix | update vite to 5.4.15 | + + + + + +# 19.2.4 (2025-03-19) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------- | +| [0a4e96bda](https://github.com/angular/angular-cli/commit/0a4e96bda054876332c5603a3bc972c3ec1eb0bf) | fix | replace `@angular/platform-browser-dynamic` with `@angular/platform-browser` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [b0b643e46](https://github.com/angular/angular-cli/commit/b0b643e46f1009be66423fdff568d042717c5e2b) | fix | ensure errors for missing component resources | +| [2cd763e89](https://github.com/angular/angular-cli/commit/2cd763e893788cfb38260d48eef40afa574a6a70) | fix | ensure relative karma stack traces for test failures | + + + + + +# 17.3.13 (2025-03-13) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [22901df02](https://github.com/angular/angular-cli/commit/22901df0261812a3408ff9d7a7690bf6b87ec2a3) | fix | update babel packages | + + + + + +# 19.2.3 (2025-03-13) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [5a739820b](https://github.com/angular/angular-cli/commit/5a739820be5cc7cb25e159a1f2283db92e741f78) | fix | update babel packages | + + + + + +# 18.2.15 (2025-03-13) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [255c8a50d](https://github.com/angular/angular-cli/commit/255c8a50d2214747c8121e963afcd96cbff39293) | fix | update babel packages | + + + + + +# 19.2.2 (2025-03-12) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [0ee24e29b](https://github.com/angular/angular-cli/commit/0ee24e29b9bb24e92ca3159a13a21fac78974fd7) | fix | record analytics for nested schematics | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [4575265f0](https://github.com/angular/angular-cli/commit/4575265f0b6dcfe81a729f60264e148d93302a10) | fix | exclude all entrypoints of a library from prebundling | +| [83fcffbb7](https://github.com/angular/angular-cli/commit/83fcffbb7d2ede1b08b4145dcedd46ef328bb2f8) | fix | handle postcss compilation errors gracefully | +| [78297ee47](https://github.com/angular/angular-cli/commit/78297ee47c9c381b08cd3649d369765c0b73d4f9) | fix | provide `extract-i18n` does not respect | +| [b18b9c8f2](https://github.com/angular/angular-cli/commit/b18b9c8f249df7b79caebc5ffca07198c14b9a72) | fix | remove duplicate prebundling warning | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [e6e8ce960](https://github.com/angular/angular-cli/commit/e6e8ce960a8048e7bfbaafa4ea013bb05d9897aa) | fix | prevent stream draining if `write` does not return a boolean | + + + + + +# 19.2.1 (2025-03-05) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [4c35b5721](https://github.com/angular/angular-cli/commit/4c35b5721b146d3c27f200c2688073c20dbe0a19) | fix | prevent accidental deletion of `main.ts` during application builder migration | +| [d7f9cb578](https://github.com/angular/angular-cli/commit/d7f9cb578d164aba830751cffb035bf8d962eca2) | fix | prevent error when tsconfig file is missing in application builder migration | + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [3ebd7ca7c](https://github.com/angular/angular-cli/commit/3ebd7ca7caeb266308856f47af06bea641b1f8e8) | fix | improve error message when configuration is missing | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [c07330967](https://github.com/angular/angular-cli/commit/c0733096797d45a5cd3ffc18f89a5c75a521accb) | fix | allow component HMR with a service worker | +| [c989c91c3](https://github.com/angular/angular-cli/commit/c989c91c37cab9571bdfaa91cbd806acd9cf9d19) | fix | exclude component styles from 'any' and 'all' budget calculations | +| [96e5dcb5f](https://github.com/angular/angular-cli/commit/96e5dcb5f14b8d16520974b80bb531a190be2343) | fix | handle undefined `less` stylesheet sourcemap values | + + + + + +# 19.2.0 (2025-02-26) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [fe8d83a1f](https://github.com/angular/angular-cli/commit/fe8d83a1f6b5e212d6d51d8f042141c3792ed1cf) | fix | add additional checks for application builder usage | +| [adf4ea5d4](https://github.com/angular/angular-cli/commit/adf4ea5d4ccb252132301111153619178c5bdabe) | fix | remove animations module from ng new app | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [ef7ea536f](https://github.com/angular/angular-cli/commit/ef7ea536feae128b9fabaa124cde2bdad3802cba) | feat | add aot option to jest | +| [523d539c6](https://github.com/angular/angular-cli/commit/523d539c6633ab223723162f425e0ef2b7b4ff71) | feat | add aot option to karma | +| [a00a49a65](https://github.com/angular/angular-cli/commit/a00a49a65ae68e6e0f9856d8d0f4d9914031cd05) | feat | add aot to WTR schema | +| [2bae1a9c0](https://github.com/angular/angular-cli/commit/2bae1a9c0c9eff8087b67c7890b87dc1c279c809) | fix | support aot option for karma browser builder | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [11fab9c7d](https://github.com/angular/angular-cli/commit/11fab9c7dde950e46b2a23d239bb9e29b20f5eff) | feat | add application builder karma testing to package | +| [a5fcf8044](https://github.com/angular/angular-cli/commit/a5fcf804428b835cd79bd8fad55c16e614c2be3a) | fix | provide karma stack trace sourcemap support | +| [964fb778b](https://github.com/angular/angular-cli/commit/964fb778b7d9e4811a6987eddc4f0a010bb713f6) | fix | support per component updates of multi-component files | +| [f836be9e6](https://github.com/angular/angular-cli/commit/f836be9e676575fccd4d74eddbc5bf647f7ff1bd) | fix | support Vite `allowedHosts` option for development server | +| [0ddf6aafa](https://github.com/angular/angular-cli/commit/0ddf6aafaa65b3323dc4ee6251d75794ae862ec7) | fix | utilize bazel stamp instead of resolving peer dependency versions | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [9726cd084](https://github.com/angular/angular-cli/commit/9726cd084b76fe605405d562a18d8af91d6657d8) | feat | Add support for route matchers with fine-grained render mode control | + + + + + +# 19.1.9 (2025-02-26) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [2d361e9b0](https://github.com/angular/angular-cli/commit/2d361e9b0ae5409d7ab23f50b089da16497623c1) | fix | always disable JSON stats with dev-server | + + + + + +# 19.1.8 (2025-02-19) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------- | +| [f76cee637](https://github.com/angular/angular-cli/commit/f76cee6378d1fb103a47c4c9006df344029491c9) | fix | correctly parse and resolve relative schematic collection names on Windows | +| [ceba7739c](https://github.com/angular/angular-cli/commit/ceba7739cc72835d080a3c2246209a635212a607) | fix | prefer installed package as fallback when listing package groups | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [c54b9996a](https://github.com/angular/angular-cli/commit/c54b9996adb23ebc0a5e1e159ac4a9b54cbf2f1a) | fix | pass missing options to Karma esbuild builder | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [2f60a24dd](https://github.com/angular/angular-cli/commit/2f60a24dd76b3345aef666e7a84099863349c53e) | fix | suppress asset missing warning for `/index.html` requests | +| [b8f7952b7](https://github.com/angular/angular-cli/commit/b8f7952b783a83649364107c78f0fb87ac7b3cf3) | fix | update critical CSS inlining to support `autoCsp` | + + + + + +# 19.1.7 (2025-02-12) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [de73b1c0c](https://github.com/angular/angular-cli/commit/de73b1c0c2d5748818d2e94f93f2640d4c6b949c) | fix | include default export for Express app | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [8890a5f76](https://github.com/angular/angular-cli/commit/8890a5f76c252fe383a632880df476e5f63ef931) | fix | always provide Vite client helpers with development server | +| [df1d38846](https://github.com/angular/angular-cli/commit/df1d388465b6f0d3aab5fb4f011cbbe74d3058f4) | fix | configure Vite CORS option | +| [a13a49d95](https://github.com/angular/angular-cli/commit/a13a49d95be61d2a2458962d57318f301dede502) | fix | exclude unmodified files from logs with `--localize` | +| [0826315fa](https://github.com/angular/angular-cli/commit/0826315fac1c3fd2d22aa0ea544bd59ef9ed8781) | fix | handle unlocalizable files correctly in localized prerender | +| [d2e1c8e9f](https://github.com/angular/angular-cli/commit/d2e1c8e9f5c03a410d8204a5f9b11b4ad9cc9eaa) | perf | cache translated i18n bundles for faster builds | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [f5d974576](https://github.com/angular/angular-cli/commit/f5d97457622897b41e73a859dd1f218fa962be15) | fix | accurately calculate content length for static pages with `\r\n` | +| [c26ea1619](https://github.com/angular/angular-cli/commit/c26ea1619095102b21176435af826cf53f0054b1) | fix | properly handle baseHref with protocol | + + + + + +# 17.3.12 (2025-02-12) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------- | +| [d83237028](https://github.com/angular/angular-cli/commit/d832370285adccbf955963a5115cf9b9bf54a08d) | fix | update vite to version 5.4.14 | + + + + + +# 19.1.6 (2025-02-05) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [3f7042672](https://github.com/angular/angular-cli/commit/3f704267223d1881ea40e9de4e6381b9d0e43fe6) | fix | remove additional newline after standalone property | +| [e9778dba0](https://github.com/angular/angular-cli/commit/e9778dba0d75e7f528b600d51504a583485bd033) | fix | skip ssr migration when `@angular/ssr` is not a dependency | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------- | +| [27f833186](https://github.com/angular/angular-cli/commit/27f8331865de35044ddeda7a8c05bb2700b0be6a) | fix | avoid pre-transform errors with Vite pre-bundling | +| [8f6ee7ed9](https://github.com/angular/angular-cli/commit/8f6ee7ed933ea7394e14fe46d141427839008040) | fix | ensure full rebuild after initial error build in watch mode | +| [2b9c00f68](https://github.com/angular/angular-cli/commit/2b9c00f686145a8613dc2ce7f494193622e02625) | fix | prevent fallback to serving main.js for unknown requests | +| [45abd15b7](https://github.com/angular/angular-cli/commit/45abd15b781bb5bb067a7a52e7a809bb9d141c75) | fix | prevent server manifest generation when no server features are enabled | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [5bf5e5fd2](https://github.com/angular/angular-cli/commit/5bf5e5fd20e3c33a274a936dd1ce00e07b860226) | fix | prioritize the first matching route over subsequent ones | + + + + + +# 19.1.5 (2025-01-29) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------- | +| [14e3a71e4](https://github.com/angular/angular-cli/commit/14e3a71e46e12a556323fff48998794eecab9896) | fix | update library schematic to use `@angular-devkit/build-angular:ng-packagr` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [d53d25fc1](https://github.com/angular/angular-cli/commit/d53d25fc1b80388158643dbdd37aa49b0aa790e0) | fix | allow tailwindcss 4.x as a peer dependency | +| [bd9d379f0](https://github.com/angular/angular-cli/commit/bd9d379f0401a19d527dc896a96b2671b4c4ed76) | fix | disable TypeScript `removeComments` option | +| [e73e9102e](https://github.com/angular/angular-cli/commit/e73e9102e3098882dd76a8dbf800d4ba414e0e27) | fix | handle empty module case to avoid TypeError | +| [bb413456e](https://github.com/angular/angular-cli/commit/bb413456e95a9be49feba95415915ce2ef39b1b5) | fix | keep background referenced HMR update chunks | +| [2011d3428](https://github.com/angular/angular-cli/commit/2011d34286784156b8c09bb8c6d376d8f902bc00) | fix | support template updates that also trigger global stylesheet changes | +| [688019946](https://github.com/angular/angular-cli/commit/688019946358b176eacc872ece72987387a603f1) | fix | update vite to version 6.0.11 | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [94643d54d](https://github.com/angular/angular-cli/commit/94643d54da1ddadcec1c169aa844a716bec612f6) | fix | enhance dynamic route matching for better performance and accuracy | +| [747557aa0](https://github.com/angular/angular-cli/commit/747557aa0aad00f1df2ce7912ab49775e19c60dc) | fix | redirect to locale pathname instead of full URL | +| [bbbc1eb7a](https://github.com/angular/angular-cli/commit/bbbc1eb7a0c295e0bc4aea95b7292ba484f8a360) | fix | rename `provideServerRoutesConfig` to `provideServerRouting` | + + + + + +# 18.2.14 (2025-01-29) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------- | +| [9d34d28ec](https://github.com/angular/angular-cli/commit/9d34d28ec2965e1b9753556b2721d25ab05c655b) | fix | remove unused `vite` dependency | + + + + + +# 18.2.13 (2025-01-29) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [deeaf1883](https://github.com/angular/angular-cli/commit/deeaf18836efddfa1ee56a25e44944ba444d35ac) | fix | correctly select package versions in descending order during `ng add` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------- | +| [fdddf2c08](https://github.com/angular/angular-cli/commit/fdddf2c0844081667a09f2ffe0b16f77384959b2) | fix | update vite to version 5.4.14 | + + + + + +# 19.1.4 (2025-01-22) + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [aa6f0d051](https://github.com/angular/angular-cli/commit/aa6f0d051179d31aad2c3be7b79f9fda8de60f34) | fix | ensure collections can be resolved via test runner in pnpm workspaces | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------- | +| [ff8192a35](https://github.com/angular/angular-cli/commit/ff8192a355ca38edb34fb0cfe08ef133629f3f63) | fix | correct path for `/@ng/components` on Windows | +| [14d2f7ca0](https://github.com/angular/angular-cli/commit/14d2f7ca0e930fceeea53d307db79f0e963c1d53) | fix | include extracted routes in the manifest during prerendering | +| [c87a38f5b](https://github.com/angular/angular-cli/commit/c87a38f5b25b3cddd1b2a1ee4b443b10cf03b767) | fix | only issue invalid i18n config error for duplicate `subPaths` with inlined locales | +| [d50788cf9](https://github.com/angular/angular-cli/commit/d50788cf9f799ffbe9ba0edde425e6833623686d) | fix | replace deprecation of `i18n.baseHref` with a warning | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [bcc5fab75](https://github.com/angular/angular-cli/commit/bcc5fab750c0029e16ad91d277f88113a60b7fa1) | fix | prevent route matcher error when SSR routing is not used | +| [9bacf3981](https://github.com/angular/angular-cli/commit/9bacf3981995626cf935cf1620c391338de1c9df) | fix | properly manage catch-all routes with base href | +| [59c757781](https://github.com/angular/angular-cli/commit/59c75778112383816da2f729d5ae80705b5828fa) | fix | unblock route extraction with `withEnabledBlockingInitialNavigation` | + + + + + +# 19.1.3 (2025-01-20) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [7d8c34172](https://github.com/angular/angular-cli/commit/7d8c34172bf29fbf61c0c0114c419903804b6b38) | fix | allow asset changes to reload page on incremental updates | +| [9fa29af37](https://github.com/angular/angular-cli/commit/9fa29af374060a05a19b32d1f0dee954ec70f451) | fix | handle relative `@ng/components` | +| [c4de34703](https://github.com/angular/angular-cli/commit/c4de34703f8b17ac96e66f889fa0e3ffff524831) | fix | perform full reload for templates with `$localize` usage | + + + + + +# 19.1.2 (2025-01-17) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [939d1612a](https://github.com/angular/angular-cli/commit/939d1612add13bab9aed6cc77bce0e17555bfe3b) | fix | perform incremental background file updates with component updates | +| [304027207](https://github.com/angular/angular-cli/commit/30402720707b7a8b9042a6046692d62a768cdc64) | fix | prevent full page reload on HMR updates with SSR enabled | +| [148acbd58](https://github.com/angular/angular-cli/commit/148acbd58a13b1ba8c4a3349bd6042c24a9f93b5) | fix | reset component updates on dev-server index request | + + + + + +# 19.1.1 (2025-01-16) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------- | +| [298506751](https://github.com/angular/angular-cli/commit/298506751f2b3788fa2def7f7b4012e9e5465047) | fix | resolve HMR-prefixed files in SSR with Vite | + + + + + +# 19.1.0 (2025-01-15) + +## Deprecations + +### @angular/build + +- The `baseHref` option under `i18n.locales` and `i18n.sourceLocale` in `angular.json` is deprecated in favor of `subPath`. + + The `subPath` defines the URL segment for the locale, serving as both the HTML base HREF and the directory name for output. By default, if not specified, `subPath` will use the locale code. + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [02825eec5](https://github.com/angular/angular-cli/commit/02825eec53456384ba5b9c19f25dde3cfc95d796) | feat | use `@angular/build` package in library generation schematic | +| [88431b756](https://github.com/angular/angular-cli/commit/88431b7564d6757898744597a67fcdc178413128) | fix | application migration should migrate ng-packagr builder package | + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [2b8a02bac](https://github.com/angular/angular-cli/commit/2b8a02bac098d4ac4f31b0e74bedfc739171e30b) | feat | require build schemas from modules | +| [fe1ae6933](https://github.com/angular/angular-cli/commit/fe1ae6933998104c144b2c8854f362289c8d91c6) | fix | avoid Node.js resolution for relative builder schema | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [ce7c4e203](https://github.com/angular/angular-cli/commit/ce7c4e203d0312d21d4a3d1955f9c97bdf3e06d2) | fix | handle Windows drive letter case insensitivity in path functions | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [2f55209dd](https://github.com/angular/angular-cli/commit/2f55209dd24602bdf8c27ef083f96b5f55166971) | fix | update `Rule` type to support returning a `Promise` of `Tree` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [2c9d7368f](https://github.com/angular/angular-cli/commit/2c9d7368fc30f3488152e35ac468db5f2a9432f2) | feat | add ng-packagr builder to the package | +| [0a570c0c2](https://github.com/angular/angular-cli/commit/0a570c0c2e64c61ce9969975a21c0d9aac8d9f3b) | feat | add support for customizing URL segments with i18n | +| [298b554a7](https://github.com/angular/angular-cli/commit/298b554a7a40465444b4c508e2250ecbf459ea47) | feat | enable component template hot replacement by default | +| [d350f357b](https://github.com/angular/angular-cli/commit/d350f357b2a74df828ec022e03754d59cc680848) | fix | correctly validate locales `subPath` | +| [8aa1ce608](https://github.com/angular/angular-cli/commit/8aa1ce60808c073634237d03045626d379a51183) | fix | handle loaders correctly in SSR bundles for external packages | +| [3b7e6a8c6](https://github.com/angular/angular-cli/commit/3b7e6a8c6e2e018a85b437256040fd9c8161d537) | fix | invalidate component template updates with dev-server SSR | +| [8fa682e57](https://github.com/angular/angular-cli/commit/8fa682e571dbba4bf249ceb3ca490c4ddd4d7fe5) | fix | remove deleted assets from output during watch mode | +| [48cae815c](https://github.com/angular/angular-cli/commit/48cae815cfd0124217c1b5bc8c92dfdb0b150101) | fix | skip vite SSR warmup file configuration when SSR is disabled | +| [ba16ad6b5](https://github.com/angular/angular-cli/commit/ba16ad6b56e9a1ae0f380141bc1e1253a75fcf6b) | fix | support incremental build file results in watch mode | +| [955acef3d](https://github.com/angular/angular-cli/commit/955acef3d504ac924bd813f401fa9b49edbd337b) | fix | trigger browser reload on asset changes with Vite dev server | +| [e74300a2c](https://github.com/angular/angular-cli/commit/e74300a2cbc666482992fa8d6dbfeef37f3a9db5) | fix | use component updates for component style HMR | +| [6a19c217e](https://github.com/angular/angular-cli/commit/6a19c217eaebf9c0bffba8482545efc375fd2a8a) | fix | warn when using both `isolatedModules` and `emitDecoratorMetadata` | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------- | +| [8d7a51dfc](https://github.com/angular/angular-cli/commit/8d7a51dfc9658aa2f0f0c527435c05c2b10f34e5) | feat | add `modulepreload` for lazy-loaded routes | +| [41ece633b](https://github.com/angular/angular-cli/commit/41ece633b3d42ef110bf6085fe0783ab2a56efcd) | feat | redirect to preferred locale when accessing root route without a specified locale | +| [3feecddbb](https://github.com/angular/angular-cli/commit/3feecddbba0d0559da10a45ad4040faf8e9d5198) | fix | disable component boostrapping when running route extraction | +| [6edb90883](https://github.com/angular/angular-cli/commit/6edb90883733040d77647cf24dea7f53b1b6ceaa) | fix | throw error when using route matchers | + + + + + +# 19.0.7 (2025-01-08) + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [95c22aeff](https://github.com/angular/angular-cli/commit/95c22aeff4560a199416a20c3622301c5c690119) | fix | provide better error when builder is not defined | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------- | +| [028652992](https://github.com/angular/angular-cli/commit/028652992f0f9cc6fec5de35be7ecf74ec3947c5) | fix | preserve css type for jasmine.css | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [f7522342a](https://github.com/angular/angular-cli/commit/f7522342a8dd99543422629db6dcf1228c5d7279) | fix | add asset tracking to application builder watch files | +| [e973643bf](https://github.com/angular/angular-cli/commit/e973643bfbe47447ca522ca59b07a94fe6c03e0b) | fix | do not mark Babel \_defineProperty helper function as pure | +| [881095eec](https://github.com/angular/angular-cli/commit/881095eec5cdc80fe79de8fdbe05ba985bf8210a) | fix | enable serving files with bundle-like names | +| [db10af0b3](https://github.com/angular/angular-cli/commit/db10af0b3a775619ac71acdd0258cc58e96e948c) | fix | fix incorrect budget calculation | +| [c822f8f15](https://github.com/angular/angular-cli/commit/c822f8f154b75a8b8e48e32953bee7ec2763fd13) | fix | handle relative URLs when constructing new URLs during server fetch | +| [b43c648b0](https://github.com/angular/angular-cli/commit/b43c648b02c181c1d98cd3293d89ad8b131a3f51) | fix | mitigate JS transformer worker execArgv errors | +| [1f2481a4f](https://github.com/angular/angular-cli/commit/1f2481a4f5155368aa571fc6679e3ef8af0ce56f) | fix | pass `define` option defined in application builder to Vite prebundling | +| [c94f568a4](https://github.com/angular/angular-cli/commit/c94f568a412384bb8e4b66928f41b60220c8b7f4) | fix | warn when `@angular/localize/init` is imported directly | + + + + + +# 19.0.6 (2024-12-18) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------ | +| [db7421231](https://github.com/angular/angular-cli/commit/db7421231c3da7bbbfde72dc35642aaf005fbeca) | fix | jasmine.clock with app builder | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [5fbc105ed](https://github.com/angular/angular-cli/commit/5fbc105ed0cb76106916660d99fc53d7480dcbc8) | fix | force HTTP/1.1 in dev-server SSR with SSL | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [2f4df6b2b](https://github.com/angular/angular-cli/commit/2f4df6b2be458b3651df49f3bced923e8df4d547) | fix | correctly resolve pre-transform resources in Vite SSR without AppEngine | +| [0789a9e13](https://github.com/angular/angular-cli/commit/0789a9e133fed4edc29b108630b2cf91e157127e) | fix | ensure correct `Location` header for redirects behind a proxy | + + + + + +# 19.0.5 (2024-12-12) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [6c319e44c](https://github.com/angular/angular-cli/commit/6c319e44c707b93e430da93fe815ba839ab18ea1) | fix | fix webpack config transform for karma | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------- | +| [251bd9f22](https://github.com/angular/angular-cli/commit/251bd9f226f73529e824b131fa8d08b77aa00d09) | fix | Fixing auto-csp edge cases where | +| [1047b8635](https://github.com/angular/angular-cli/commit/1047b8635699d55886fff28cbf02d36df224958d) | fix | handle external `@angular/` packages during SSR ([#29094](https://github.com/angular/angular-cli/pull/29094)) | +| [376ee9966](https://github.com/angular/angular-cli/commit/376ee996699a9610984f3d3e36b3331557dbeaca) | fix | provide component HMR update modules to dev-server SSR | +| [5ea9ce376](https://github.com/angular/angular-cli/commit/5ea9ce3760a191d13db08f5ae7448ce089e8eacd) | fix | use consistent path separators for template HMR identifiers | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [b3c6c7eb2](https://github.com/angular/angular-cli/commit/b3c6c7eb2cc796796d99758368706b0b8682ae69) | fix | include `Content-Language` header when locale is set | +| [4203efb90](https://github.com/angular/angular-cli/commit/4203efb90a38fe2f0d45fabab80dc736e8ca2b7b) | fix | disable component bootstrapping during route extraction | + + + + + +# 19.0.4 (2024-12-05) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------ | +| [23667ed4a](https://github.com/angular/angular-cli/commit/23667ed4aa0bedbb591dc0284116402dc42ed95c) | fix | handle windows spec collisions | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [fc41f50b5](https://github.com/angular/angular-cli/commit/fc41f50b53bbffead017b420105eed5bd8573ac1) | fix | show error when Node.js built-ins are used during `ng serve` | +| [14451e275](https://github.com/angular/angular-cli/commit/14451e2754caff2c9800cca17e11ffa452575f09) | perf | reuse TS package.json cache when rebuilding | + + + + + +# 19.0.3 (2024-12-04) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [4e82ca180](https://github.com/angular/angular-cli/commit/4e82ca180b330199b3dffadd9d590c8245dc7785) | fix | correctly select package versions in descending order during `ng add` | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------- | +| [28a51cc5e](https://github.com/angular/angular-cli/commit/28a51cc5e4a08f9e9627a1ec160ce462d18b88d2) | fix | add required type to `CanDeactivate` guard ([#29004](https://github.com/angular/angular-cli/pull/29004)) | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [f26e1b462](https://github.com/angular/angular-cli/commit/f26e1b462ab012b0863f0889bcd60f5e07ca6fd2) | fix | add timeout to route extraction | +| [ab4e77c75](https://github.com/angular/angular-cli/commit/ab4e77c75524d42485ac124f4786ab54bc6c404a) | fix | allow .json file replacements with application builds | +| [06690d87e](https://github.com/angular/angular-cli/commit/06690d87eb590853eed6166857c9c1559d38d260) | fix | apply define option to JavaScript from scripts option | +| [775e6f780](https://github.com/angular/angular-cli/commit/775e6f7808e6edb89d29b72ee5bdc6d2b26cb30e) | fix | avoid deploy URL usage on absolute preload links | +| [21f21eda3](https://github.com/angular/angular-cli/commit/21f21eda39c62e284c6cbee0d0ebfe271f605239) | fix | ensure correct handling of `index.output` for SSR | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [75cf47e71](https://github.com/angular/angular-cli/commit/75cf47e71b0584e55750d5350932494f689a7e96) | fix | apply HTML transformation to CSR responses | +| [5880a0230](https://github.com/angular/angular-cli/commit/5880a02306d9f81f030fcdc91fc6aaeb1986e652) | fix | correctly handle serving of prerendered i18n pages | +| [277b8a378](https://github.com/angular/angular-cli/commit/277b8a3786d40cb8477287dcb3ef191ec8939447) | fix | ensure compatibility for `Http2ServerResponse` type | + + + + + +# 19.0.2 (2024-11-25) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------- | +| [2f53e2af5](https://github.com/angular/angular-cli/commit/2f53e2af55794795979232b0f3e95359299e1aee) | fix | skip SSR routing prompt in webcontainer | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [f9da163f8](https://github.com/angular/angular-cli/commit/f9da163f8852800763844ae89e85eaafe0c37f2b) | fix | minimize reliance on esbuild `inject` to prevent code reordering | +| [c497749e6](https://github.com/angular/angular-cli/commit/c497749e670e916e17a4e7fb0acb1abe26d9bd9a) | fix | prevent errors with parameterized routes when `getPrerenderParams` is undefined | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [c8cd90e0f](https://github.com/angular/angular-cli/commit/c8cd90e0f601a6baa05b84e45bbd37b4bf6049f5) | fix | handle nested redirects not explicitly defined in router config | + + + + + +# 19.0.1 (2024-11-21) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [b63123f20](https://github.com/angular/angular-cli/commit/b63123f20702bd53ea99888b83b4253810ae0a09) | fix | use stylePreprocessorOptions | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [74461da64](https://github.com/angular/angular-cli/commit/74461da6439b70b5348c99682842ae20043d9b61) | fix | ensure accurate content length for server assets | +| [1b4dcedd5](https://github.com/angular/angular-cli/commit/1b4dcedd594b5d9a1701cd8d1e9874742c05e47f) | fix | use `sha256` instead of `sha-256` as hash algorithm name | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------ | +| [8bd2b260e](https://github.com/angular/angular-cli/commit/8bd2b260e2008f1ffc71af0e55b27884c3660c54) | fix | handle baseHref that start with `./` | + + + + + +# 19.0.0 (2024-11-19) + +## Breaking Changes + +### @schematics/angular + +- The app-shell schematic is no longer compatible with Webpack-based builders. + +### @angular-devkit/build-angular + +- The `browserTarget` option has been removed from the DevServer and ExtractI18n builders. `buildTarget` is to be used instead. +- Protractor is no longer supported. + + Protractor was marked end-of-life in August 2023 (see https://protractortest.org/). Projects still relying on Protractor should consider migrating to another E2E testing framework, several support solid migration paths from Protractor. + - https://angular.dev/tools/cli/end-to-end + - https://blog.angular.dev/the-state-of-end-to-end-testing-with-angular-d175f751cb9c + +### @angular-devkit/core + +- The deprecated `fileBuffer` function is no longer available. Update your code to use `stringToFileBuffer` instead to maintain compatibility. + + **Note:** that this change does not affect application developers. + +### @angular/build + +- The `@angular/localize/init` polyfill will no longer be added automatically to projects. To prevent runtime issues, ensure that this polyfill is manually included in the "polyfills" section of your "angular.json" file if your application relies on Angular localization features. + +### @angular/ssr + +- The `CommonEngine` API now needs to be imported from `@angular/ssr/node`. + + **Before** + + ```ts + import { CommonEngine } from '@angular/ssr'; + ``` + + **After** + + ```ts + import { CommonEngine } from '@angular/ssr/node'; + ``` + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [37693c40e](https://github.com/angular/angular-cli/commit/37693c40e3afc4c6dd7c949ea658bdf94146c9d8) | feat | add package manager option to blank schematic | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------- | +| [a381a3db1](https://github.com/angular/angular-cli/commit/a381a3db187f7b20e5ec8d1e1a1f1bd860426fcd) | feat | add option to export component as default | +| [755f3a07f](https://github.com/angular/angular-cli/commit/755f3a07f5fe485c1ed8c0c6060d6d5c799c085c) | feat | add option to setup new workspace or application as zoneless mode | +| [cfca5442e](https://github.com/angular/angular-cli/commit/cfca5442ec01cc4eff4fe75822eb7ef780ccfef1) | feat | integrate `withEventReplay()` in `provideClientHydration` for new SSR apps | +| [292a4b7c2](https://github.com/angular/angular-cli/commit/292a4b7c2f62828606c42258db524341f4a6391e) | feat | update app-shell and ssr schematics to adopt new Server Rendering API | +| [b1504c3bc](https://github.com/angular/angular-cli/commit/b1504c3bcca4d4c313e5d795ace8b074fb1f8890) | fix | component spec with export default | +| [4b4e000dd](https://github.com/angular/angular-cli/commit/4b4e000dd60bb43df5c8694eb8a0bc0b45d1cf8d) | fix | don't show server routing prompt when using `browser` builder | +| [4e2a5fe15](https://github.com/angular/angular-cli/commit/4e2a5fe155006e7154326319ed39e77e5693d9b3) | fix | enable opt-in for new `@angular/ssr` feature | +| [fcf7443d6](https://github.com/angular/angular-cli/commit/fcf7443d626d1f3e828ebfad464598f7b9ddef12) | fix | explicitly set standalone:false | +| [7992218a9](https://github.com/angular/angular-cli/commit/7992218a9c22ea9469bd3386c7dc1d5efc6e51f9) | fix | remove `declaration` and `sourceMap` from default tsconfig | +| [9e6ab1bf2](https://github.com/angular/angular-cli/commit/9e6ab1bf231b35aceb989337fac55a6136594c5d) | fix | use default import for `express` | + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [201b60e1d](https://github.com/angular/angular-cli/commit/201b60e1dd25b4abb7670e21d103b67d4eda0e14) | feat | handle string key/value pairs, e.g. --define | +| [b847d4460](https://github.com/angular/angular-cli/commit/b847d4460c352604e1935d494efd761915caaa3f) | fix | recommend optional application update migration during v19 update | +| [f249e7e85](https://github.com/angular/angular-cli/commit/f249e7e856bf16e8c5f154fdb8aff36421649a1b) | perf | enable Node.js compile code cache when available | +| [ecc107d83](https://github.com/angular/angular-cli/commit/ecc107d83bfdfd9d5dd1087e264892d60361625c) | perf | enable Node.js compile code cache when available | + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------- | +| [78f76485f](https://github.com/angular/angular-cli/commit/78f76485fe315ffd0262c1a3732092731235828b) | feat | merge object options from CLI | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------- | +| [0a4ef3026](https://github.com/angular/angular-cli/commit/0a4ef302635e4665ae9881746867dd80ca0d2dc7) | feat | karma-coverage w/ app builder | +| [dcbdca85c](https://github.com/angular/angular-cli/commit/dcbdca85c7fe1a7371b8f6662e0f68e24d56102e) | feat | karma+esbuild+watch | +| [54594b5ab](https://github.com/angular/angular-cli/commit/54594b5abfa4c9301cc369e5dea5f76b71e51ab0) | feat | support karma with esbuild | +| [ea5ae68da](https://github.com/angular/angular-cli/commit/ea5ae68da9e7f2b598bae2ca9ac8be9c20ce7888) | fix | bring back style tags in browser builder | +| [476f94f51](https://github.com/angular/angular-cli/commit/476f94f51a3403d03ceb9f58ffb4a3564cc52e5a) | fix | fix --watch regression in karma | +| [25d928b4f](https://github.com/angular/angular-cli/commit/25d928b4fde00ae8396f6b9cfcd92b5254fc49aa) | fix | fix hanging terminal when `browser-sync` is not installed | +| [2ec877dd0](https://github.com/angular/angular-cli/commit/2ec877dd0dede8f3ee849fe83b4a4427bab96447) | fix | handle basename collisions | +| [ab6e19e1f](https://github.com/angular/angular-cli/commit/ab6e19e1f9a8781334821048522abe86b149c9c3) | fix | handle main field | +| [43e7aae22](https://github.com/angular/angular-cli/commit/43e7aae2284ff15e0282c9d9597c4f31cf1f60a4) | fix | remove double-watch in karma | +| [1e37b5939](https://github.com/angular/angular-cli/commit/1e37b59396a2f815d1671ccecc03ff8441730391) | fix | serve assets | +| [9d7613db9](https://github.com/angular/angular-cli/commit/9d7613db9bf8b397d5896fcdf6c7b0efeaffa5d5) | fix | zone.js/testing + karma + esbuild | +| [e40384e63](https://github.com/angular/angular-cli/commit/e40384e637bc6f92c28f4e572f986ca902938ba2) | refactor | remove deprecated `browserTarget` | +| [62877bdf2](https://github.com/angular/angular-cli/commit/62877bdf2b0449d8c12a167c59d0c24c77467f37) | refactor | remove Protractor builder and schematics | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------ | +| [0d8a1006d](https://github.com/angular/angular-cli/commit/0d8a1006d4629d8af1144065ea237ab30916e66e) | refactor | remove deprecated `fileBuffer` function in favor of `stringToFileBuffer` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------- | +| [b6951f448](https://github.com/angular/angular-cli/commit/b6951f4482418f65e4bd1c15d5f7f051c91d59db) | feat | add `sass` to `stylePreprocessorOptions` in application builder | +| [efb434136](https://github.com/angular/angular-cli/commit/efb434136d8c8df207747ab8fd87b7e2116b7106) | feat | Auto-CSP support as a part of angular.json schema | +| [816e3cb86](https://github.com/angular/angular-cli/commit/816e3cb868961c57a68783601b919370076c41dc) | feat | enable component stylesheet hot replacement by default | +| [3b00fc908](https://github.com/angular/angular-cli/commit/3b00fc908d4f07282e89677928e00665c8578ab5) | feat | introduce `outputMode` option to the application builder | +| [7d883a152](https://github.com/angular/angular-cli/commit/7d883a152e978112245a98f2f737764caa76ec0f) | feat | introduce `ssr.experimentalPlatform` option | +| [c48d6947e](https://github.com/angular/angular-cli/commit/c48d6947ed17eab19822a97492e3686bcf059494) | feat | set development/production condition | +| [f63072668](https://github.com/angular/angular-cli/commit/f63072668e44254da78170445ac2417c7bc1aa18) | feat | utilize `ssr.entry` during prerendering to enable access to local API routes | +| [bbc290133](https://github.com/angular/angular-cli/commit/bbc290133fc93186980ca3c43f221847ba8e858a) | feat | utilize `ssr.entry` in Vite dev-server when available | +| [5a7a2925b](https://github.com/angular/angular-cli/commit/5a7a2925b1f649eabbeb0a75452978cddb3f243d) | fix | add missing redirect in SSR manifest | +| [06e5176c2](https://github.com/angular/angular-cli/commit/06e5176c2d3b27aaeb117374a8ae402c6a4c6319) | fix | add warning when `--prerendering` or `--app-shell` are no-ops | +| [ecaf870b5](https://github.com/angular/angular-cli/commit/ecaf870b5cddd5d43d297f1193eb11b8f73757c0) | fix | always clear dev-server error overlay on non-error result | +| [f8677f6a9](https://github.com/angular/angular-cli/commit/f8677f6a9ba155b04c692814a1bc13f5cc47d94d) | fix | always record component style usage for HMR updates | +| [099e477a8](https://github.com/angular/angular-cli/commit/099e477a8f1bbcf9d0f415dc6fd4743107c967f7) | fix | avoid hashing development external component stylesheets | +| [3602bbb77](https://github.com/angular/angular-cli/commit/3602bbb77b8924e89978427d9115f0b1fd7d46b7) | fix | avoid overwriting inline style bundling additional results | +| [71534aadc](https://github.com/angular/angular-cli/commit/71534aadc403404e2dc9bc12054f32c3ed157db9) | fix | check referenced files against native file paths | +| [fed31e064](https://github.com/angular/angular-cli/commit/fed31e064611894934c86ed36e8b0808029d4143) | fix | correctly use dev-server hmr option to control stylesheet hot replacement | +| [b86bb080e](https://github.com/angular/angular-cli/commit/b86bb080e3a58a3320b2f68fb79edcdc98bfa7e9) | fix | disable dev-server websocket when live reload is disabled | +| [7c50ba9e2](https://github.com/angular/angular-cli/commit/7c50ba9e2faca445c196c69e972ac313547dda54) | fix | ensure `index.csr.html` is always generated when prerendering or SSR are enabled | +| [efb2232df](https://github.com/angular/angular-cli/commit/efb2232df5475699a44d0f76a70e2d7de4a71f5a) | fix | ensure accurate content size in server asset metadata | +| [18a8584ea](https://github.com/angular/angular-cli/commit/18a8584ead439d044760fe2abb4a7f657a0b10e3) | fix | ensure SVG template URLs are considered templates with external stylesheets | +| [7502fee28](https://github.com/angular/angular-cli/commit/7502fee28a057b53e60b97f55b5aff5281019f1b) | fix | Exclude known `--import` from execArgv when spawning workers | +| [2551df533](https://github.com/angular/angular-cli/commit/2551df533d61400c0fda89db77a93378480f5047) | fix | fully disable component style HMR in JIT mode | +| [c41529cc1](https://github.com/angular/angular-cli/commit/c41529cc1d762cf508eccf46c44256df21afe24f) | fix | handle `APP_BASE_HREF` correctly in prerendered routes | +| [87a90afd4](https://github.com/angular/angular-cli/commit/87a90afd4600049b184b32f8f92a0634e25890c0) | fix | incomplete string escaping or encoding | +| [1bb68ba68](https://github.com/angular/angular-cli/commit/1bb68ba6812236a135c1599031bf7e1b7e0d1d79) | fix | move lmdb to optionalDependencies | +| [a995c8ea6](https://github.com/angular/angular-cli/commit/a995c8ea6d17778af031c2f9797e52739ea4dc81) | fix | prevent prerendering of catch-all routes | +| [1654acf0f](https://github.com/angular/angular-cli/commit/1654acf0ff3010b619a22d11f17eec9975d8e2a2) | fix | relax constraints on external stylesheet component id | +| [0d4558ea5](https://github.com/angular/angular-cli/commit/0d4558ea516a4b8716f2442290e05354c502a49e) | fix | set `ngServerMode` during vite prebundling | +| [55d7f01b6](https://github.com/angular/angular-cli/commit/55d7f01b66f4867aad4598574582e8505f201c82) | fix | simplify disabling server features with `--no-server` via command line | +| [cf0228b82](https://github.com/angular/angular-cli/commit/cf0228b828fc43b1b33d48dc0977ff59abb597c2) | fix | skip wildcard routes from being listed as prerendered routes | +| [af52fb49b](https://github.com/angular/angular-cli/commit/af52fb49bdd913af8af9bfbe36be279fce70de39) | fix | synchronize import/export conditions between bundler and TypeScript | +| [6c618d495](https://github.com/angular/angular-cli/commit/6c618d495c54394eb2b87aee36ba5436c06557bd) | fix | update logic to support both internal and external SSR middlewares | +| [bfa8fec9b](https://github.com/angular/angular-cli/commit/bfa8fec9b17d421925a684e2b642dee70165a879) | fix | use named export `reqHandler` for server.ts request handling | +| [c8e1521a2](https://github.com/angular/angular-cli/commit/c8e1521a2bd5b47c811e5d7f9aea7f57e92a4552) | fix | workaround Vite CSS ShadowDOM hot replacement | +| [d6a34034d](https://github.com/angular/angular-cli/commit/d6a34034d7489c09753090642ade4c606cd98ece) | refactor | remove automatic addition of `@angular/localize/init` polyfill and related warnings | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------- | +| [92209dd2e](https://github.com/angular/angular-cli/commit/92209dd2e93af450e3fc657609efe95c6a6b3963) | feat | add `createRequestHandler` and `createNodeRequestHandler `utilities | +| [41fb2ed86](https://github.com/angular/angular-cli/commit/41fb2ed86056306406832317178ca5d94aa110e2) | feat | Add `getHeaders` Method to `AngularAppEngine` and `AngularNodeAppEngine` for handling pages static headers | +| [f346ee8a8](https://github.com/angular/angular-cli/commit/f346ee8a8819bb2eaf0ffb3d5523b00093be09e5) | feat | add `isMainModule` function | +| [d66aaa3ca](https://github.com/angular/angular-cli/commit/d66aaa3ca458e05b535bec7c1dcb98b0e9c5202e) | feat | add server routing configuration API | +| [bca568389](https://github.com/angular/angular-cli/commit/bca56838937f942c5ef902f5c98d018582188e84) | feat | dynamic route resolution using Angular router | +| [30c25bf68](https://github.com/angular/angular-cli/commit/30c25bf6885fefea6094ec1815e066e4c6ada097) | feat | export `AngularAppEngine` as public API | +| [455b5700c](https://github.com/angular/angular-cli/commit/455b5700c29845829235e17efec320e634553108) | feat | expose `writeResponseToNodeResponse` and `createWebRequestFromNodeRequest` in public API | +| [9692a9054](https://github.com/angular/angular-cli/commit/9692a9054c3cdbf151df01279c2d268332b1a032) | feat | improve handling of aborted requests in `AngularServerApp` | +| [576ff604c](https://github.com/angular/angular-cli/commit/576ff604cd739a9f41d588fa093ca2568e46826c) | feat | introduce `AngularNodeAppEngine` API for Node.js integration | +| [3c9697a8c](https://github.com/angular/angular-cli/commit/3c9697a8c34a5e0f3470bde73f11f9f32107f42e) | feat | introduce new hybrid rendering API | +| [4b09887a9](https://github.com/angular/angular-cli/commit/4b09887a9c82838ccb7a6c95d66225c7875e562b) | feat | move `CommonEngine` API to `/node` entry-point | +| [d43180af5](https://github.com/angular/angular-cli/commit/d43180af5f3e7b29387fd06625bd8e37f3ebad95) | fix | add missing peer dependency on `@angular/platform-server` | +| [74b3e2d51](https://github.com/angular/angular-cli/commit/74b3e2d51c6cf605abd05da81dc7b4c3ccd9b3ea) | fix | add validation to prevent use of `provideServerRoutesConfig` in browser context | +| [2640bf7a6](https://github.com/angular/angular-cli/commit/2640bf7a680300acf18cf6502c57a00e0a5bfda9) | fix | correct route extraction and error handling | +| [44077f54e](https://github.com/angular/angular-cli/commit/44077f54e9a95afa5c1f85cf198aaa3412ee08d8) | fix | designate package as side-effect free | +| [df4e1d360](https://github.com/angular/angular-cli/commit/df4e1d3607c2d5bf71d1234fa730e63cd6ab594b) | fix | enable serving of prerendered pages in the App Engine | +| [0793c78cf](https://github.com/angular/angular-cli/commit/0793c78cfcbfc5d55fe6ce2cb53cada684bcb8dc) | fix | ensure wildcard RenderMode is applied when no Angular routes are defined | +| [65b6e75a5](https://github.com/angular/angular-cli/commit/65b6e75a5dca581a57a9ac3d61869fdd20f7dc2e) | fix | export `RESPONSE_INIT`, `REQUEST`, and `REQUEST_CONTEXT` tokens | +| [4ecf63a77](https://github.com/angular/angular-cli/commit/4ecf63a777871bf214bf42fe1738c206bde3201c) | fix | export PrerenderFallback | +| [50df63196](https://github.com/angular/angular-cli/commit/50df631960550049e7d1779fd2c8fbbcf549b8ef) | fix | improve handling of route mismatches between Angular server routes and Angular router | +| [3cf7a5223](https://github.com/angular/angular-cli/commit/3cf7a522318e34daa09f29133e8c3444f154ca0b) | fix | initialize the DI tokens with `null` to avoid requiring them to be set to optional | +| [85df4011b](https://github.com/angular/angular-cli/commit/85df4011ba27254ddb7f22dae550272c9c4406dd) | fix | resolve `bootstrap is not a function` error | +| [e9c9e4995](https://github.com/angular/angular-cli/commit/e9c9e4995e39d9d62d10fe0497e0b98127bc8afa) | fix | resolve circular dependency issue from main.server.js reference in manifest | +| [64c52521d](https://github.com/angular/angular-cli/commit/64c52521d052f850aa7ea1aaadfd8a9fcee9c387) | fix | show error when multiple routes are set with `RenderMode.AppShell` | +| [280ebbda4](https://github.com/angular/angular-cli/commit/280ebbda4c65e19b83448a1bb0de056a2ee5d1c6) | fix | support for HTTP/2 request/response handling | +| [fb05e7f0a](https://github.com/angular/angular-cli/commit/fb05e7f0abd9d68ac03f243c7774260619b8a623) | fix | use wildcard server route configuration on the '/' route when the app router is empty | +| [12ff37adb](https://github.com/angular/angular-cli/commit/12ff37adbed552fc0db97251c30c889ef00e50f3) | perf | cache generated inline CSS for HTML | +| [1d70e3b46](https://github.com/angular/angular-cli/commit/1d70e3b4682806a55d6f7ddacbafcbf615b2a10c) | perf | cache resolved entry-points | +| [f460b91d4](https://github.com/angular/angular-cli/commit/f460b91d46ea5b0413596c4852c80d71d5308910) | perf | integrate ETags for prerendered pages | +| [e52ae7f6f](https://github.com/angular/angular-cli/commit/e52ae7f6f5296a9628cc4a517e82339ac54925bb) | perf | prevent potential stampede in entry-points cache | + + + + + +# 18.2.12 (2024-11-14) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [c3925ed7f](https://github.com/angular/angular-cli/commit/c3925ed7f8e34fd9816cf5a4e8d63c2c45d31d53) | fix | support default options for multiselect list x-prompt | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [c8bee8415](https://github.com/angular/angular-cli/commit/c8bee8415099dfa03d5309183ebbbaab73b2a0eb) | fix | allow .js file replacements in all configuration cases | +| [93f552112](https://github.com/angular/angular-cli/commit/93f552112c2bbd10bc0cee4afcae5b012242636c) | fix | improve URL rebasing for hyphenated Sass namespaced variables | + + + + + +# 18.2.11 (2024-10-30) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [87ec15ba2](https://github.com/angular/angular-cli/commit/87ec15ba266436b7b99b0629beaea3e487434115) | fix | show error message when error stack is undefined | + + + + + +# 18.2.10 (2024-10-23) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [7b775f4e0](https://github.com/angular/angular-cli/commit/7b775f4e008652777bbe7b788dabed02bcc70cc7) | fix | update `http-proxy-middleware` to `3.0.3` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [b1e5f51f9](https://github.com/angular/angular-cli/commit/b1e5f51f9111d7da56ebe64cad51936ad659782d) | fix | Address build issue in Node.js LTS versions with prerendering or SSR | + + + + + +# 17.3.11 (2024-10-23) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [8bad9cee0](https://github.com/angular/angular-cli/commit/8bad9cee08982fffa5ce8244148b491e66191ed8) | fix | update `http-proxy-middleware` to `2.0.7` | + + + + + +# 18.2.9 (2024-10-16) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [237f7c5d0](https://github.com/angular/angular-cli/commit/237f7c5d0355e0a90b23156d3aa97f4328c869e7) | fix | update browserslist config to include last 2 Android major versions | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [d749ba6a3](https://github.com/angular/angular-cli/commit/d749ba6a3c3dd7a90317bd9b46e858a842f27696) | fix | allow direct bundling of TSX files with application builder | +| [b91c82d89](https://github.com/angular/angular-cli/commit/b91c82d8997c0009ed4bbf5e9cd9c82cb1f7f755) | fix | avoid race condition in sass importer | + + + + + +# 18.2.8 (2024-10-09) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [b522002ff](https://github.com/angular/angular-cli/commit/b522002fff763cda2ae1c746efcb2638d0099184) | fix | add validation for component and directive class name | +| [dfd2d5c05](https://github.com/angular/angular-cli/commit/dfd2d5c0500777fa5aea91519f6657aed7f3b7d7) | fix | include `index.csr.html` in resources asset group | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [9445916f9](https://github.com/angular/angular-cli/commit/9445916f9b5b9da69623bf86735264d8a5f26fb3) | fix | `Ctrl + C` not terminating dev-server with SSR | +| [9b5cfaa8c](https://github.com/angular/angular-cli/commit/9b5cfaa8ce9d12bf450e7527d479ce7a879ea1b8) | fix | always generate a new hash for optimized chunk | + + + + + +# 18.2.7 (2024-10-02) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [3f98193d6](https://github.com/angular/angular-cli/commit/3f98193d6963464bd04b510c2d045938f1418ff3) | fix | support single quote setting in JetBrains IDEs | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------- | +| [8274184e1](https://github.com/angular/angular-cli/commit/8274184e1c6fa43cc5101018b6fa86fd636a90ba) | fix | add `animate` to valid self-closing elements | +| [2648e811e](https://github.com/angular/angular-cli/commit/2648e811e7c71e8a68d1eb418d7dcdae42ebf9ff) | fix | add few more SVG elements animateMotion, animateTransform, and feBlend etc. to valid self-closing elements | +| [736e126e4](https://github.com/angular/angular-cli/commit/736e126e4836e1c3df32c0ab9ee40e58c16503c0) | fix | separate Vite cache by project | + + + + + +# 18.2.6 (2024-09-25) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [9d0b67124](https://github.com/angular/angular-cli/commit/9d0b67124e4855c5c4a2101b64f8ed86f8624561) | fix | allow missing HTML file request to fallback to index | +| [5fea635b2](https://github.com/angular/angular-cli/commit/5fea635b20b29429e355072c5adc5bf2a597a267) | fix | update rollup to 4.22.4 | + + + + + +# 17.3.10 (2024-09-25) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------- | +| [30489d8fd](https://github.com/angular/angular-cli/commit/30489d8fd1cf738162d95333fe462eea58ba460f) | fix | update vite to 4.1.8 | + + + + + +# 18.2.5 (2024-09-18) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [707431625](https://github.com/angular/angular-cli/commit/7074316257bd736e0d3393368fc93dec9604b49e) | fix | support HTTP HEAD requests for virtual output files | +| [1032b3da1](https://github.com/angular/angular-cli/commit/1032b3da1a0f3aaf63d2fd3cd8c6fd3b0d0b578c) | fix | update vite to `5.4.6` | + + + + + +# 16.2.16 (2024-09-18) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------- | +| [12aca0060](https://github.com/angular/angular-cli/commit/12aca0060492c73cec1bbc231119dde6a4b52607) | fix | update vite to 4.5.5 | + + + + + +# 18.2.4 (2024-09-11) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [765309a2e](https://github.com/angular/angular-cli/commit/765309a2e1bcd3bb07ff87062fc2dc04e4bce16f) | fix | prevent transformation of Node.js internal dependencies by Vite | + + + + + +# 18.2.3 (2024-09-04) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [482076612](https://github.com/angular/angular-cli/commit/482076612cac4b6565fc1b5e89ff9ca207653f87) | fix | update `webpack-dev-middleware` to `7.4.2` | + + + + + +# 18.2.2 (2024-08-29) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [504b00b93](https://github.com/angular/angular-cli/commit/504b00b93b80eec4185838b426c0f6acaa3a148e) | fix | clear context in Karma by default for single run executions | +| [82b76086e](https://github.com/angular/angular-cli/commit/82b76086eb519c224981038dfa55b2ec3cfec0b4) | fix | update webpack to `5.94.0` | + + + + + +# 17.3.9 (2024-08-29) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [e2c5c034d](https://github.com/angular/angular-cli/commit/e2c5c034d96962fe6f358285e376630c71ac9673) | fix | clear context in Karma by default for single run executions | +| [88501f3d5](https://github.com/angular/angular-cli/commit/88501f3d5586f72ee0900b8d351af3d72bdc0dee) | fix | upgrade webpack to `5.94.0` | + + + + + +# 16.2.15 (2024-08-29) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [f596a3d5d](https://github.com/angular/angular-cli/commit/f596a3d5def009b5130440113e3c9b450eb98040) | fix | clear context in Karma by default for single run executions | +| [56fa051bd](https://github.com/angular/angular-cli/commit/56fa051bd92ad47ea089499a488f3566a93375e6) | fix | upgrade webpack to `5.94.0` | + + + + + +# 18.2.1 (2024-08-21) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [05a274a01](https://github.com/angular/angular-cli/commit/05a274a01365c21f69c0412f3455acd14cc6ddc5) | fix | prevent bypassing select/checkbox prompts on validation failure | + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [94e27c88b](https://github.com/angular/angular-cli/commit/94e27c88bb968589bc8b9b5d6536ce6c0ba0b24f) | fix | prevent bypassing select/checkbox prompts on validation failure | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [ddeb2b2b9](https://github.com/angular/angular-cli/commit/ddeb2b2b93eaa9d8b659d17357aa2b7a9dc509ce) | fix | remove outdated browser-esbuild option warning | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------- | +| [83b2699ab](https://github.com/angular/angular-cli/commit/83b2699abbf58a7c90d2339fa4a01d67aa2d2d33) | fix | improve error message when an unhandled exception occurs during prerendering | +| [0be4038a5](https://github.com/angular/angular-cli/commit/0be4038a503626e2e9f44d68fe5599cc6028dd8e) | fix | support reading on-disk files during i18n extraction | + + + + + +# 18.2.0 (2024-08-14) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [4da922e4f](https://github.com/angular/angular-cli/commit/4da922e4f4e905a1274e70adca1d875c025b8b46) | feat | use isolatedModules in generated project | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [24aaf1e37](https://github.com/angular/angular-cli/commit/24aaf1e37f49735ce97fe72fced3d53b51d6b631) | feat | support import attribute based loader configuration | + + + + + +# 18.1.4 (2024-08-07) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [f8b092711](https://github.com/angular/angular-cli/commit/f8b092711481a5754ea93bce65d706d261873810) | fix | allow explicitly disabling TypeScript incremental mode | +| [f3a5970fc](https://github.com/angular/angular-cli/commit/f3a5970fca0a196b1ac905306257d65bab3b1325) | fix | lazy load Node.js inspector for dev server | + + + + + +# 18.1.3 (2024-07-31) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [a28615d7d](https://github.com/angular/angular-cli/commit/a28615d7dd3f6503f257756058fe182ce6f6b052) | fix | add CSP `nonce` attribute to script tags when inline critical CSS is disabled | +| [747a1447c](https://github.com/angular/angular-cli/commit/747a1447c1855a1bb918e5f55e4ba4182235f88a) | fix | prevent build failures with remote CSS imports when Tailwind is configured | +| [c0933f2c0](https://github.com/angular/angular-cli/commit/c0933f2c0354a13ba3f752f29b24054177697faa) | fix | resolve error with `extract-i18n` builder for libraries | + + + + + +# 18.1.2 (2024-07-24) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [5b9378a3b](https://github.com/angular/angular-cli/commit/5b9378a3be3a1c4f465ba471a120a2edd3a4d2f8) | fix | account for HTML base HREF for dev-server externals | +| [3e4ea77d7](https://github.com/angular/angular-cli/commit/3e4ea77d755edce2c88d55b76860e6e91fb03f3c) | fix | correctly detect comma in Sass URL lexer | +| [d868270f1](https://github.com/angular/angular-cli/commit/d868270f1baf0fd5f2c5677691cc9c4e88b47d8f) | fix | prevent redirection loop | +| [3573ac655](https://github.com/angular/angular-cli/commit/3573ac6555ead2afc34979e284426a0204fff42c) | fix | serve HTML files directly | + + + + + +# 18.1.1 (2024-07-17) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------- | +| [4f6cee272](https://github.com/angular/angular-cli/commit/4f6cee2721b52427624370f3f81f3edc140774e7) | fix | skip undefined files when generating budget stats | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [96dc7e6ed](https://github.com/angular/angular-cli/commit/96dc7e6ed3317e354fae82d1b42b31250e96d89d) | fix | remove Vite "/@id/" prefix for explicit external dependencies | +| [bdef39801](https://github.com/angular/angular-cli/commit/bdef3980160db8c27ae103444a41275351434b78) | fix | resolve only ".wasm" files | + + + + + +# 18.1.0 (2024-07-10) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [6d266c146](https://github.com/angular/angular-cli/commit/6d266c146c9e141396236b93f2bfafcb261fd7aa) | fix | add fallbacks for migration package resolution | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [22e05dcb4](https://github.com/angular/angular-cli/commit/22e05dcb4a9ae963997c58fad86125ca51b19a36) | fix | generate new projects with ECMAScript standard class field behavior | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [687a6c7ec](https://github.com/angular/angular-cli/commit/687a6c7eca740a98129196908689a44c181b33a5) | feat | add `--inspect` option to the dev-server | +| [628d87a94](https://github.com/angular/angular-cli/commit/628d87a9474ad2792b69bfbc501a2c5960b27db9) | feat | support WASM/ES Module integration proposal | +| [3e359da8d](https://github.com/angular/angular-cli/commit/3e359da8dfdbfdb99be13f5c52a7e429c106d4ad) | fix | address rxjs undefined issues during SSR prebundling | +| [4ff914a16](https://github.com/angular/angular-cli/commit/4ff914a16525350050c5e8359fb59f9d6f243d27) | fix | allow additional module preloads up to limit | +| [fb8e3c39a](https://github.com/angular/angular-cli/commit/fb8e3c39a8b265003e98c8c6b5a9ec898223249f) | fix | allow top-level await in zoneless applications | +| [83b75af9f](https://github.com/angular/angular-cli/commit/83b75af9f74af0742a6a78cf7531866b7ba434b6) | fix | check inlineSourceMap option with isolated modules optimization | +| [cd97134a6](https://github.com/angular/angular-cli/commit/cd97134a6e1468c6806c2bd753c934ec91bc3927) | fix | normalize paths during module resolution in Vite | +| [13d2100dd](https://github.com/angular/angular-cli/commit/13d2100ddcc670d69f2d7754890b80eae2e7649a) | fix | read WASM file from script location on Node.js | +| [3091956f5](https://github.com/angular/angular-cli/commit/3091956f503754f313dbf98a8de6d21d3d01ebe3) | fix | support import attributes in JavaScript transformer | +| [dd94a831b](https://github.com/angular/angular-cli/commit/dd94a831b4ce1ca55289fca1818aa26195e81dbc) | perf | enable dependency prebundling for server dependencies | +| [3acb77683](https://github.com/angular/angular-cli/commit/3acb7768317bb05a9cd73fa64e081b5ca0326189) | perf | use direct transpilation with isolated modules | + + + + + +# 18.0.7 (2024-07-03) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [67bf90131](https://github.com/angular/angular-cli/commit/67bf9013151c4e6a13c91ecf4afd78c863d9e33f) | fix | make `ng update` to keep newline at the end of package.json | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [9b43ecbd0](https://github.com/angular/angular-cli/commit/9b43ecbd0395027548781a19345fbcd82261d4f4) | fix | reduce the number of max workers to available CPUs minus one | +| [03dad6806](https://github.com/angular/angular-cli/commit/03dad680676c4b2272b65a51dd62d74360e20b78) | fix | rollback terser to `5.29.2` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [fc928f638](https://github.com/angular/angular-cli/commit/fc928f6386061f34f7cd3ef6bb6d25aa4a33a800) | fix | correctly name entry points to match budgets | +| [2d51e8607](https://github.com/angular/angular-cli/commit/2d51e86077c4687224e931f49c82a907f5229ae5) | fix | redirect to path with trailing slash for asset directories | +| [16f1c1e01](https://github.com/angular/angular-cli/commit/16f1c1e010090596b7d7c3911f01728e3feecc4d) | fix | reduce the number of max workers to available CPUs minus one | + + + + + +# 18.0.6 (2024-06-26) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [98a8a8a78](https://github.com/angular/angular-cli/commit/98a8a8a781fd7901f2e1c1d2eb22975ac65f0329) | fix | show JavaScript cache store initialization warning | + + + + + +# 18.0.5 (2024-06-20) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [5c705e800](https://github.com/angular/angular-cli/commit/5c705e800c17237d82bc9065f22e30b720ddcec0) | fix | update schematics to use RouterModule when --routing flag is present | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [86e031dc7](https://github.com/angular/angular-cli/commit/86e031dc7ef6c736e431caf973aca1233d912060) | fix | use istanbul-lib-instrument directly for karma code coverage | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [bdd168f37](https://github.com/angular/angular-cli/commit/bdd168f37f7757e0c02971e21e90479555a6e703) | fix | add CSP nonce to script with src tags | +| [405c14809](https://github.com/angular/angular-cli/commit/405c1480917d50c677be178c817b845f30cc8cce) | fix | automatically resolve `.mjs` files when using Vite | +| [7360a346e](https://github.com/angular/angular-cli/commit/7360a346ed1b329c0620301ce0e0464d5569a42f) | fix | use Node.js available parallelism for default worker count | + + + + + +# 18.0.4 (2024-06-13) + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------- | +| [791ef809d](https://github.com/angular/angular-cli/commit/791ef809d8dfec8fde844e916973a05b2eb5c9d9) | fix | do not reference sourcemaps in web workers and global stylesheet bundles when hidden setting is enabled | +| [20fc6ca05](https://github.com/angular/angular-cli/commit/20fc6ca057e5190155474e7377bf9f22aab597dd) | fix | generate module preloads next to script elements in index HTML | +| [3a1bf5c8a](https://github.com/angular/angular-cli/commit/3a1bf5c8a52d6ec1eb337f0937bf073de2ea0b62) | fix | Initiate PostCSS only once | +| [78c611754](https://github.com/angular/angular-cli/commit/78c6117544afa1aa69ef5485f1c3b77b1207f6f1) | fix | issue warning when auto adding `@angular/localize/init` | + + + + + +# 18.0.3 (2024-06-05) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------- | +| [b709d2a24](https://github.com/angular/angular-cli/commit/b709d2a243926f1ab01e8c8a78d68fc57ab4cab3) | fix | add `schema.json` options to parsed command, also when a version is passed to `ng add @` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [43a2a7d13](https://github.com/angular/angular-cli/commit/43a2a7d137328c1f6f865b76585a92f4e5058b13) | fix | avoid escaping rebased Sass URL values | +| [9acb5c7ca](https://github.com/angular/angular-cli/commit/9acb5c7ca8e6d14379e39f56d2498c0276214210) | fix | disable JS transformer persistent cache on web containers | +| [346df4909](https://github.com/angular/angular-cli/commit/346df490976e39d791db5ecfa14eab6a5ad8d99d) | fix | improve Sass rebaser ident token detection | +| [6526a5f59](https://github.com/angular/angular-cli/commit/6526a5f590fbc7f26b9e613af3b5c497e30603b5) | fix | watch all related files during a Sass error | + + + + + +# 18.0.2 (2024-05-29) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [9967c04b8](https://github.com/angular/angular-cli/commit/9967c04b86c6928509c80af7144b342616e9681b) | fix | check both application builder packages in SSR schematic | +| [92b48ab14](https://github.com/angular/angular-cli/commit/92b48ab144fbe5b8f89d371b0a8fa94d0d17b72c) | fix | set builders `assets` option correctly for new applications | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [3bb06c37d](https://github.com/angular/angular-cli/commit/3bb06c37dc20d7af358f007b9928de71f39545d2) | fix | disable Worker wait loop for Sass compilations in web containers | +| [c4cf35923](https://github.com/angular/angular-cli/commit/c4cf359233e1044864539383912b9ba0432e149d) | fix | print Sass `@warn` location | +| [352879804](https://github.com/angular/angular-cli/commit/3528798042a232779478bf82b4d4f6521fab4c30) | fix | support valid self-closing MathML tags in HTML index file | +| [476f3084a](https://github.com/angular/angular-cli/commit/476f3084aff333a45c4937950abdba65cd420eba) | fix | support valid self-closing SVG tags in HTML index file | + +### @angular/pwa + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [acbffd236](https://github.com/angular/angular-cli/commit/acbffd2368d3c979b26a4541d3f44387fdba0651) | fix | set manifest `icons` location to match `assets` builder option | + + + + + +# 18.0.1 (2024-05-23) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------- | +| [01842f515](https://github.com/angular/angular-cli/commit/01842f5154fe0ec41226d1dd28e099bf57f3d2c9) | fix | use angular.dev in readme | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [7d253e9cd](https://github.com/angular/angular-cli/commit/7d253e9cd0bb6df829fd4229465f4334d5c134bb) | fix | avoid rebasing URLs with function calls | +| [6b6a76a99](https://github.com/angular/angular-cli/commit/6b6a76a998980392d78e1cabc5e5fe4af0ced01c) | fix | disable persistent disk caching inside webcontainers by default | +| [ba70a50b6](https://github.com/angular/angular-cli/commit/ba70a50b6bc45a6b07ff24feed3b36915294063b) | fix | handle esbuild-browser `polyfills` option as `string` during `ng serve` | +| [706423aca](https://github.com/angular/angular-cli/commit/706423acad2c431c4125166d078dbad804719d95) | fix | only import persistent cache store with active caching | + + + + + +# 18.0.0 (2024-05-22) + +## Breaking Changes + +### @angular/cli + +- The `ng doc` command has been removed without a replacement. To perform searches, please visit www.angular.dev +- Node.js support for versions <18.19.1 and <20.11.1 has been removed. + +### @angular-devkit/build-angular + +- By default, the index.html file is no longer emitted in the browser directory when using the application builder with SSR. Instead, an index.csr.html file is emitted. This change is implemented because in many cases server and cloud providers incorrectly treat the index.html file as a statically generated page. If you still require the old behavior, you can use the `index` option to specify the `output` file name. + + ```json + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/my-app", + "index": { + "input": "src/index.html", + "output": "index.html" + } + } + } + } + ``` + +- The support for the legacy Sass build pipeline, previously accessible via `NG_BUILD_LEGACY_SASS` when utilizing webpack-based builders, has been removed. + +## Deprecations + +### @angular-devkit/schematics + +- `NodePackageLinkTask` in `@angular-devkit/schematics`. A custom task should be created instead. + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------- | +| [ac3019570](https://github.com/angular/angular-cli/commit/ac301957093d0689c98f7debe98fbb2546c9b442) | feat | add `ng dev` alias to `ng serve` | +| [4087728c3](https://github.com/angular/angular-cli/commit/4087728c3e6350d85d653e9d053249ff77e639e6) | feat | support for Node.js v22 | +| [41ab6c8c3](https://github.com/angular/angular-cli/commit/41ab6c8c3486d7cf7c41c18ae3b603376f647605) | fix | add `--version` option | +| [df4dde95d](https://github.com/angular/angular-cli/commit/df4dde95daa12d5b08b3c4e937f4b4048d645254) | fix | add `@angular/build` package to update group list | +| [1039f6d79](https://github.com/angular/angular-cli/commit/1039f6d7997523dd4657c5c2a06631e6075b7bc0) | fix | change update guide link to angular.dev | +| [f4670fcb1](https://github.com/angular/angular-cli/commit/f4670fcb1af20a53501b557fc0e6126afce766d5) | fix | eliminate prompts during `ng version` command | +| [a99ec6a54](https://github.com/angular/angular-cli/commit/a99ec6a5453fb732500ef7abff67f76511a74da3) | fix | keep cli package first in update package group metadata | +| [dd786d495](https://github.com/angular/angular-cli/commit/dd786d495ce6e7d759b0b225b2efe25fb5727d08) | fix | only add --version option on default command | +| [03eee0545](https://github.com/angular/angular-cli/commit/03eee0545095ff958ac86cb5dfad44692ef018ae) | refactor | remove `ng doc` command | +| [c7b208555](https://github.com/angular/angular-cli/commit/c7b208555e34cc5ebf9cf2d335d257e72297cae9) | refactor | remove support for Node.js versions <18.19.1 and <20.11.1 | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [b2ac5fac7](https://github.com/angular/angular-cli/commit/b2ac5fac7d66ccd027f766565fa17c6a3bb18e44) | feat | allow application migration to use new build package in projects where possible | +| [6530aa11b](https://github.com/angular/angular-cli/commit/6530aa11bed5ef67d611e8aed268bd20345cf0e6) | feat | replace `assets` with `public` directory | +| [725883713](https://github.com/angular/angular-cli/commit/72588371385bebeea1003dff4d1d0a2ca9854321) | feat | use eventCoalescing option by default (standalone bootstrap) | +| [508d97da7](https://github.com/angular/angular-cli/commit/508d97da76b5359bc8029888ff0e9cfc59a6139c) | feat | use ngZoneEventCoalescing option by default (module bootstrap) | +| [f452589e2](https://github.com/angular/angular-cli/commit/f452589e2c921448b76a138a5f34ba92ad05e297) | feat | use TypeScript bundler module resolution for new projects | +| [95a4d6ee5](https://github.com/angular/angular-cli/commit/95a4d6ee56d80dce012cf2306422bb7fd8e0e32d) | fix | add less dependency in application migration if needed | +| [c46aa084f](https://github.com/angular/angular-cli/commit/c46aa084f53be7ebdb8cc450bd81907222d00275) | fix | add postcss dependency in application migration if needed | +| [157329384](https://github.com/angular/angular-cli/commit/157329384809d723c428a043712a331493826748) | fix | add spaces around eventCoalescing option | +| [23cc337aa](https://github.com/angular/angular-cli/commit/23cc337aa34c919e344ab001f5efbb8fe9ce3c7c) | fix | keep deployUrl option when migrating to application builder | + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [ddd08efef](https://github.com/angular/angular-cli/commit/ddd08efefecfe9b74db6a866a1bed0216380a28a) | fix | resolve builder aliases from containing package | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- | +| [53c319aaa](https://github.com/angular/angular-cli/commit/53c319aaa95049b8558df80e57fa0a6318003121) | feat | add support for the `poll` option in the library builder | +| [83d1d233a](https://github.com/angular/angular-cli/commit/83d1d233a2eded71fcdd5fec4b1a90bdd4dbf132) | feat | enhance Sass rebasing importer for resources URL defined in variables and handling of external paths | +| [d51cb598a](https://github.com/angular/angular-cli/commit/d51cb598a74aba313aee212656de506004a041e6) | feat | inject event-dispatch in SSR HTML page | +| [0b03829bc](https://github.com/angular/angular-cli/commit/0b03829bcefea5c250c6a9ff880a737fcc351b2e) | feat | move i18n extraction for application builder to new build system package | +| [4ffe07aa2](https://github.com/angular/angular-cli/commit/4ffe07aa24a0fc9ff48461e9c3664d96e92317cf) | feat | move Vite-based dev-server for application builder to new build system package | +| [d1c632af9](https://github.com/angular/angular-cli/commit/d1c632af9a98d4e8975f198cf205194e2ebff209) | feat | support native async/await when app is zoneless | +| [37fc7f0cc](https://github.com/angular/angular-cli/commit/37fc7f0ccf3b8e6f31a0c5b2eaf4aee52f439472) | fix | disable Vite prebundling when script optimizations are enabled | +| [2acf95a94](https://github.com/angular/angular-cli/commit/2acf95a94993e51876d4004d2c3bc0a04be0a419) | fix | do not generate an `index.html` file in the browser directory when using SSR. | +| [8a54875cb](https://github.com/angular/angular-cli/commit/8a54875cbb654f95d5213b2d84190bd3814d6810) | fix | handle wrapping of class expressions emitted by esbuild | +| [97973059e](https://github.com/angular/angular-cli/commit/97973059ec56a573629f7a367757773a3cfabe17) | refactor | remove Sass legacy implementation | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ------------------------------- | +| [797584583](https://github.com/angular/angular-cli/commit/797584583138c9223bf238ae8f352e77575bd25a) | refactor | deprecate `NodePackageLinkTask` | + +### @angular/build + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------ | +| [810d213e1](https://github.com/angular/angular-cli/commit/810d213e1813dd01620173f5f999dca7bccf8ea1) | feat | introduce new official build system package | +| [b7a0792b3](https://github.com/angular/angular-cli/commit/b7a0792b3286fc98d1343f55b5df89ddf13e36bc) | fix | add a maximum rendering timeout for SSG | +| [411115303](https://github.com/angular/angular-cli/commit/41111530349db1ac199c3ac1d4eccbde8b023123) | fix | add console note about development server raw file size | +| [921fa7cf4](https://github.com/angular/angular-cli/commit/921fa7cf4adc69d3cb6ec7dd5c8d7cace33a502e) | fix | add missing `ansi-colors` and `picomatch` dependencies | +| [791cf75af](https://github.com/angular/angular-cli/commit/791cf75afb0b3b5892c41296bc4049a2c10926e8) | fix | check both potential build packages in Angular version check | +| [4d7cd5e3e](https://github.com/angular/angular-cli/commit/4d7cd5e3ed303c53b2cc63720b9a577e2f46f170) | fix | correctly wrap class expressions with static properties or blocks emitted by esbuild | +| [57f448a0f](https://github.com/angular/angular-cli/commit/57f448a0f70c76c1a0ebbe941f82eec1d698e7d4) | fix | decode URL pathname decoding during SSG fetch | +| [940e382db](https://github.com/angular/angular-cli/commit/940e382db27474dba6479f57e4ffefee04cfca66) | fix | disable Vite prebundling when script optimizations are enabled | +| [70dbc7a6e](https://github.com/angular/angular-cli/commit/70dbc7a6e9a7f6d55aeb4e10e8e686b186e6cdf3) | fix | emit error for invalid self-closing element in index HTML | +| [44b401747](https://github.com/angular/angular-cli/commit/44b401747f78bab208ce863f9c08e7a12f01fe27) | fix | ensure input index HTML file triggers rebuilds when changed | +| [dff4deaeb](https://github.com/angular/angular-cli/commit/dff4deaeb366d0ff734ae02abdbaa1fcdcd901aa) | fix | ensure recreated files are watched | +| [17931166d](https://github.com/angular/angular-cli/commit/17931166d83a4b18d2f4eb81f8a445b2365c71aa) | fix | format sizes using decimal byte units consistently | +| [2085365e0](https://github.com/angular/angular-cli/commit/2085365e04c9b08dbf2024036b93609046f2f458) | fix | only generate shallow preload links for initial files | +| [33cd47c85](https://github.com/angular/angular-cli/commit/33cd47c85ea12df57ec7b244beccfa299c927765) | fix | properly configure headers for media resources and HTML page | +| [d10fece2c](https://github.com/angular/angular-cli/commit/d10fece2c17183e18d04733dec22459ced1cc1c8) | fix | properly rebase Sass url() values with leading interpolations | +| [3f2963835](https://github.com/angular/angular-cli/commit/3f2963835759fa3eed1faf64a7b87d5dcf8a6fa3) | perf | add persistent caching of JavaScript transformations | +| [a15eb7d1c](https://github.com/angular/angular-cli/commit/a15eb7d1c6a26f5d94da5566f8b4ac1810ea1361) | perf | improve rebuild time for file loader usage with prebundling | + + + + + +# 17.3.8 (2024-05-22) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [3ada6eb52](https://github.com/angular/angular-cli/commit/3ada6eb5256631ca3a951525fc9814ad0447a41f) | fix | clarify optional migration instructions during ng update | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------- | +| [4b6ba8df1](https://github.com/angular/angular-cli/commit/4b6ba8df1ab8f4801fba7ddc38812417e274d960) | fix | `SchematicTestRunner.runExternalSchematic` fails with "The encoded data was not valid for encoding utf-8" | + + + + + +# 17.3.7 (2024-05-08) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [998c72036](https://github.com/angular/angular-cli/commit/998c720363087f3f0b1748d3f875e5b536a3119d) | fix | decode URL pathname decoding during SSG fetch | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [1ab1c6c9e](https://github.com/angular/angular-cli/commit/1ab1c6c9e10ce938402355afed4602b76ac08a0e) | fix | use web standard error check for Deno support | + + + + + +# 17.3.6 (2024-04-25) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [dcec59799](https://github.com/angular/angular-cli/commit/dcec59799faac66bf25043984c11944479efcf4d) | fix | properly configure headers for media resources and HTML page | + + + + + +# 17.3.5 (2024-04-17) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [6191d06ca](https://github.com/angular/angular-cli/commit/6191d06ca735a8fd29f048f319f912076abec698) | fix | address `Unable to deserialize cloned data` issue with Yarn PnP | +| [0335d6a5d](https://github.com/angular/angular-cli/commit/0335d6a5df1c0b0706673e6152e3bf5eb65d166a) | fix | remove `type="text/css"` from `style` tag | + + + + + +# 17.3.4 (2024-04-11) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [1128bdd64](https://github.com/angular/angular-cli/commit/1128bdd642c3e60c67485970e5cd354b2fde0f98) | fix | ensure esbuild-based builders exclusively produce ESM output | + + + + + +# 16.2.14 (2024-04-11) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------- | +| [1068c3c73](https://github.com/angular/angular-cli/commit/1068c3c733a7c52e7876d43454d0ff590c99b61b) | fix | update vite to `4.5.3` | + + + + + +# 17.3.3 (2024-04-02) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [a673baf5c](https://github.com/angular/angular-cli/commit/a673baf5ce385415ded23641a2dc5cdcae8f3f5c) | fix | Revert "fix(@schematics/angular): rename SSR port env variable" | + + + + + +# 17.3.2 (2024-03-25) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [935f931ee](https://github.com/angular/angular-cli/commit/935f931eea8ddd1cb86b2275b8e384bf51e9153e) | fix | rename SSR port env variable | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [c9d436000](https://github.com/angular/angular-cli/commit/c9d4360000e6134b936781be3b0d5cf1871d44d7) | fix | `Internal server error: Invalid URL` when using a non localhost IP | +| [59fba38ec](https://github.com/angular/angular-cli/commit/59fba38ec6181c8d9c7a0636fb514c4b25aaf0cd) | fix | ensure proper resolution of linked SCSS files | +| [27dd8f208](https://github.com/angular/angular-cli/commit/27dd8f208911dbb2eda6d64efd6d1ce8c463ce35) | fix | service-worker references non-existent named index output | +| [c12907d92](https://github.com/angular/angular-cli/commit/c12907d92f8a2268541fd3bf28857dbb216ec7c9) | fix | update `webpack-dev-middleware` to `6.1.2` | + + + + + +# 16.2.13 (2024-03-25) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [5ad507e3d](https://github.com/angular/angular-cli/commit/5ad507e3d4cb27fb275d255018b9b6e735835711) | fix | `update webpack-dev-middleware` to `6.1.2` | + + + + + +# 15.2.11 (2024-03-25) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [c6feb0bb0](https://github.com/angular/angular-cli/commit/c6feb0bb0247a1cf17e17325b8c42d0d6a7d1451) | fix | `update webpack-dev-middleware` to `6.1.2` | + + + + + +# 17.3.1 (2024-03-20) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [198ca9afc](https://github.com/angular/angular-cli/commit/198ca9afcc9a043d4329c2f4032f0b9cefa11a2e) | fix | improve Sass format clarity for application style option | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------- | +| [2809971a5](https://github.com/angular/angular-cli/commit/2809971a57966cf79965c84a933f70709334c16b) | fix | only generate `server` directory when SSR is enabled | +| [3f601a14e](https://github.com/angular/angular-cli/commit/3f601a14e70540f37ef6c6559a5cd50bb6b453d7) | fix | typo in allowedHosts warning for unsupported vite options | +| [79c44adac](https://github.com/angular/angular-cli/commit/79c44adac4184408cbd1dc07989796f155cfc70e) | perf | avoid transforming empty component stylesheets | +| [cc3167f30](https://github.com/angular/angular-cli/commit/cc3167f3012aa621a7fc9277a9c8a82601f7d914) | perf | reduce build times for apps with a large number of components when utilizing esbuild-based builders | + + + + + +# 17.3.0 (2024-03-13) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [5ab71fc92](https://github.com/angular/angular-cli/commit/5ab71fc92ba26f6255e5a5c00e374709ff58d19d) | feat | update CSS/Sass import/use specifiers in application migration | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [9ca3a5450](https://github.com/angular/angular-cli/commit/9ca3a54503574674eb107d4d2b507be7ecd727ee) | feat | add `deployUrl` to application builder | +| [3821344da](https://github.com/angular/angular-cli/commit/3821344da663ded52b773752abc805ed04e28f3c) | fix | ensure proper display of build logs in the presence of warnings or errors | +| [de2d05049](https://github.com/angular/angular-cli/commit/de2d050498e16efa75459f526b9973c9e1d67050) | fix | provide better error message when server option is required but missing | + + + + + +# 17.2.3 (2024-03-06) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------- | +| [7cc8261fd](https://github.com/angular/angular-cli/commit/7cc8261fd2eb0ef1665faebec43cba447a64fd33) | fix | avoid implicit CSS file extensions when resolving | +| [259ec72d5](https://github.com/angular/angular-cli/commit/259ec72d521b3a660c54ec33aaf8bf7c838281e7) | fix | avoid marking component styles as media with no output media directory | +| [faffdfdce](https://github.com/angular/angular-cli/commit/faffdfdcebb3f71f7ef64b02114561985c592add) | fix | disable `deployUrl` when using vite dev-server | + + + + + +# 17.2.2 (2024-02-28) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [3394d3cf1](https://github.com/angular/angular-cli/commit/3394d3cf10996a93777edfb28d83cf4eb85ae40b) | fix | ensure all related stylesheets are rebuilt when an import changes | + + + + + +# 17.2.1 (2024-02-22) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [9e7c47b59](https://github.com/angular/angular-cli/commit/9e7c47b5945b368a6fd5e2544674d5a3afd63d65) | fix | allow `mts` and `cts` file replacement | +| [f2a2e9287](https://github.com/angular/angular-cli/commit/f2a2e92877298a30bc1042772be043d5db9ac729) | fix | provide Vite client code source map when loading | + + + + + +# 17.2.0 (2024-02-14) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------ | +| [b3e206741](https://github.com/angular/angular-cli/commit/b3e206741c5b59b8563b7c60a1f66c49802549e7) | feat | add support to `bun` package manager | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------ | +| [03e1aa790](https://github.com/angular/angular-cli/commit/03e1aa7904acfe9d12eaf3717d1b136159893a76) | feat | add support to `bun` package manager | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------ | +| [7f57123fd](https://github.com/angular/angular-cli/commit/7f57123fd40b345d7880cb9e2eccd4757c0fb6ee) | feat | add define build option to application builder | +| [f4f535653](https://github.com/angular/angular-cli/commit/f4f535653a34c2a7c37c51c98680b6b1766c6d0d) | feat | add JSON build logs when using the application builder | +| [b59f663e5](https://github.com/angular/angular-cli/commit/b59f663e5715e009b05bf560637c1bca3b112784) | feat | allow control of Vite-based development server prebundling | +| [8f47f1e96](https://github.com/angular/angular-cli/commit/8f47f1e965b25f3d976eda701ae2e7b7e8cccfa3) | feat | provide default and abbreviated build target support for dev-server and extract-i18n | +| [7a12074dc](https://github.com/angular/angular-cli/commit/7a12074dc940f1aa8f5347071324fa0d2fd1300b) | feat | provide option to allow automatically cleaning the terminal screen during rebuilds | +| [7c522aa87](https://github.com/angular/angular-cli/commit/7c522aa8742cd936bb0dfd30773d88f3ef92d488) | feat | support using custom postcss configuration with application builder | +| [476a68daa](https://github.com/angular/angular-cli/commit/476a68daa9746d29d3f74f68307d982d1d66f94c) | fix | add output location in build stats | +| [5e6f1a9f4](https://github.com/angular/angular-cli/commit/5e6f1a9f4362e9b12db64c7c2e609a346b17963d) | fix | avoid preloading server chunks | +| [41ea985f9](https://github.com/angular/angular-cli/commit/41ea985f9317b11cfa6627a2d3f6b34ff4dbc134) | fix | display server bundles in build stats | +| [d493609d3](https://github.com/angular/angular-cli/commit/d493609d30e1f148a7efb72bd64227521a326fbb) | fix | downgrade copy-webpack-plugin to workaround Node.js support issue | +| [8d5af1d5c](https://github.com/angular/angular-cli/commit/8d5af1d5c78ce9aa996f6ba138b99d0bb5005d46) | fix | ensure correct `.html` served with Vite dev-server | +| [944cbcdb1](https://github.com/angular/angular-cli/commit/944cbcdb1af62855febc931fce92debf28a3b2a5) | fix | limit the number of lazy chunks visible in the stats table | +| [905e13633](https://github.com/angular/angular-cli/commit/905e13633071b1db25621ae9f2762a48fe010df1) | fix | support string as plugin option in custom postcss plugin config | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------- | +| [da1c38c48](https://github.com/angular/angular-cli/commit/da1c38c486b07d5a1b2933f23f83c6231b512e0f) | fix | add `bun` to known package managers | + +### @angular/create + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------ | +| [600498f2c](https://github.com/angular/angular-cli/commit/600498f2cd3e3251e7e6e3dd3505c5e943b2986a) | feat | add support to `bun` package manager | + + + + + +# 17.1.4 (2024-02-14) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [6d2168db9](https://github.com/angular/angular-cli/commit/6d2168db92fcba1ebf82498fed85cd2b596547d3) | fix | prevent BOM errors in `package.json` during `ng update` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [bf42d6df2](https://github.com/angular/angular-cli/commit/bf42d6df2f5eda45bec80bb60315152c03f4a3dc) | fix | bypass Vite prebundling for absolute URL imports | + + + + + +# 17.1.3 (2024-02-08) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [3de3aa170](https://github.com/angular/angular-cli/commit/3de3aa170f02352fe2adf61beea221b356a40843) | fix | allow `./` baseHref when using vite based server | +| [17f47a3c9](https://github.com/angular/angular-cli/commit/17f47a3c94b434a73b9fc698872ef6230f61c781) | fix | ensure WebWorker main entry is used in output code | + + + + + +# 17.1.2 (2024-01-31) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [6815f13e3](https://github.com/angular/angular-cli/commit/6815f13e3c87eff773aa914858293c75e4fae7d2) | fix | add `required` modules as externals imports | +| [a0e306098](https://github.com/angular/angular-cli/commit/a0e306098147cf5fb7b51264c18860767fdf6316) | fix | correctly handle glob negation in proxy config when using vite | +| [235c8403a](https://github.com/angular/angular-cli/commit/235c8403a5bf8a2032da72a504e8cee441dd2d82) | fix | handle regular expressions in proxy config when using Vite | +| [5332e5b2e](https://github.com/angular/angular-cli/commit/5332e5b2ea0c9757f717e386fb162392ef2327a4) | fix | resolve absolute `output-path` when using esbuild based builders | +| [3deb0d4a1](https://github.com/angular/angular-cli/commit/3deb0d4a102fb8d8fae7617b81f62706371e03f5) | fix | return 404 for assets that are not found | + + + + + +# 17.1.1 (2024-01-24) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [8ebb754c2](https://github.com/angular/angular-cli/commit/8ebb754c2e865ffd2c38f61d50a5f4be225a0fe5) | fix | update regex to validate the project-name | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [35ebf1efd](https://github.com/angular/angular-cli/commit/35ebf1efdfa438ea713020b847826621bba0ebfc) | fix | retain trailing comma when adding providers to app config | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [88de1da92](https://github.com/angular/angular-cli/commit/88de1da92919834f620a31d8a3e6a4e2ad1e2f07) | fix | `ENOENT: no such file or directory` on Windows during component rebuild | +| [4e2586aeb](https://github.com/angular/angular-cli/commit/4e2586aeb8ec11cf951f30bbfca6422f13cfd5cc) | fix | allow package file loader option with Vite prebundling | +| [aca1cfcda](https://github.com/angular/angular-cli/commit/aca1cfcda520d9a68bc01833453c81f38c133d37) | fix | do not add internal CSS resources files in watch | +| [53258f617](https://github.com/angular/angular-cli/commit/53258f617cf6c9068e069122029ff91c62d2109e) | fix | handle load event for multiple stylesheets and CSP nonces | +| [412fe6ec6](https://github.com/angular/angular-cli/commit/412fe6ec69bfcbb1e9fb09ccbb10a086b5166689) | fix | pre-transform error when using vite with SSR | +| [45dea6f44](https://github.com/angular/angular-cli/commit/45dea6f44cb27431e4767ce16df3e84c5b6d8f9c) | fix | provide actionable error message when server bundle is missing default export | +| [4e2b23f03](https://github.com/angular/angular-cli/commit/4e2b23f0321f3ec6edfd3e20e9bf95d799de5e7f) | fix | update dependency vite to v5.0.12 | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [02d9d84c5](https://github.com/angular/angular-cli/commit/02d9d84c5da3def7e6b307b115e77233cfcf8d4b) | fix | handle load event for multiple stylesheets and CSP nonces | + + + + + +# 16.2.12 (2024-01-24) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------- | +| [5fad40162](https://github.com/angular/angular-cli/commit/5fad401628f7ddbc412d7e761a4300724f078bde) | fix | update dependency vite to v4.5.2 | + + + + + +# 17.1.0 (2024-01-17) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------- | +| [b513d89b7](https://github.com/angular/angular-cli/commit/b513d89b77dd50891a5f02ec59d1a2bffa0d36db) | feat | add optional migration to use application builder | +| [a708dccff](https://github.com/angular/angular-cli/commit/a708dccff34f62b625332555005bbd8f41380ec2) | feat | update SSR and application builder migration schematics to work with new `outputPath` | +| [4469e481f](https://github.com/angular/angular-cli/commit/4469e481fc4f74574fdd028513b57ba2300c3b34) | fix | do not trigger NPM install when using `---skip-install` and `--ssr` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [e0b274b8f](https://github.com/angular/angular-cli/commit/e0b274b8ff4d164061ca7b60248bb85ceee8f65d) | feat | add option to retain CSS special comments in global styles | +| [204794c4f](https://github.com/angular/angular-cli/commit/204794c4f8e87882af974144fff642762930b4d3) | feat | add support for `--no-browsers` in karma builder | +| [4784155bd](https://github.com/angular/angular-cli/commit/4784155bd62cfac9b29327167093e70c9c6bee41) | feat | add wildcard option for `allowedCommonJsDependencies` | +| [3b93df42d](https://github.com/angular/angular-cli/commit/3b93df42daf9eda9215ea65d8ed0efd1ef203a09) | feat | allow configuring loaders for custom file extensions in application builder | +| [cc246d50e](https://github.com/angular/angular-cli/commit/cc246d50ea8d92289c8be8dc58b376358a899ad6) | feat | allow customization of output locations | +| [15a669c1e](https://github.com/angular/angular-cli/commit/15a669c1efdc8ac18507232d6cb29794c82b94cc) | feat | allowing control of index HTML initial preload generation | +| [47a064b14](https://github.com/angular/angular-cli/commit/47a064b146d06ee7498e3aacb2f17a6283be4504) | feat | emit external sourcemaps for component styles | +| [68dae539a](https://github.com/angular/angular-cli/commit/68dae539adfa12d6088f96ac5c9f224d9bb52e17) | feat | initial experimental implementation of `@web/test-runner` builder | +| [f6e67df1c](https://github.com/angular/angular-cli/commit/f6e67df1c4f286fb1fe195b75cdaab4339ad7604) | feat | inline Google and Adobe fonts located in stylesheets | +| [364a16b7a](https://github.com/angular/angular-cli/commit/364a16b7a6d903cb176f7db627fec126b8aa05f9) | feat | move `browser-sync` as optional dependency | +| [ccba849e4](https://github.com/angular/angular-cli/commit/ccba849e48287805bd8253a03f88d5f44b2b23ae) | feat | support keyboard command shortcuts in application dev server | +| [329d80075](https://github.com/angular/angular-cli/commit/329d80075bc788de0c8e757fbd8cd69120fbec99) | fix | alllow `OPTIONS` requests to be proxied when using `vite` | +| [49ed9a26c](https://github.com/angular/angular-cli/commit/49ed9a26cb87ae629d7d4167277f7e5c4ee066f7) | fix | emit error when using prerender and app-shell builders with application builder | +| [6473b0160](https://github.com/angular/angular-cli/commit/6473b01603b55d265489840cbf32697ad663aeeb) | fix | ensure all configured assets can be served by dev server | +| [874e576b5](https://github.com/angular/angular-cli/commit/874e576b523ba675f85011388e4ce3fcc38992fa) | fix | filter explicit external dependencies for Vite prebundling | +| [2a02b1320](https://github.com/angular/angular-cli/commit/2a02b1320449e0562041bbba86e42048665402e5) | fix | fix normalization of the application builder extensions | +| [9906ab7b4](https://github.com/angular/angular-cli/commit/9906ab7b4714e1fca040f875dd91f0279f688abe) | fix | normalize asset source locations in Vite-based development server | +| [ceffafe1a](https://github.com/angular/angular-cli/commit/ceffafe1a3c8cad469b718e466e771e1d396ab14) | fix | provide better error messages for failed file reads | +| [6d7fdb952](https://github.com/angular/angular-cli/commit/6d7fdb952d49dda1301af229af138d834161c2f9) | fix | show diagnostic messages after build stats | +| [4e1f0e44d](https://github.com/angular/angular-cli/commit/4e1f0e44dca106fa299b5dd0e4145c2c3a99ab4f) | fix | the request url "..." is outside of Vite serving allow list for all assets | +| [bd26a18e7](https://github.com/angular/angular-cli/commit/bd26a18e7a9512bdad15784a19f42aaca8aec303) | fix | typo in preloadInitial option description | +| [125fb779f](https://github.com/angular/angular-cli/commit/125fb779ff394f388c2d027c1dda4a33bd8caa62) | perf | reduce TypeScript JSDoc parsing in application builder | + + + + + +# 17.0.10 (2024-01-10) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [ed1e130da](https://github.com/angular/angular-cli/commit/ed1e130dad7f9b6629f7bd31f8f0590814d0eb57) | fix | retain existing EOL when updating JSON files | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [09c32c678](https://github.com/angular/angular-cli/commit/09c32c678221746458db50f1c2f7eb92264abb16) | fix | retain existing EOL when adding imports | +| [a5c339eaa](https://github.com/angular/angular-cli/commit/a5c339eaa73eb73e2b13558a363e058500a2cfba) | fix | retain existing EOL when updating JSON files | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [3dc4db7d7](https://github.com/angular/angular-cli/commit/3dc4db7d78649eef99a2e60b1faec8844815f8e4) | fix | retain existing EOL when updating workspace config | + + + + + +# 17.0.9 (2024-01-03) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [446dfb76a](https://github.com/angular/angular-cli/commit/446dfb76a5e2a53542fae93b4400133bf7d9552e) | fix | add prerender and ssr-dev-server schemas in angular.json schema | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [88d6ca4a5](https://github.com/angular/angular-cli/commit/88d6ca4a545c2d3e35822923f2aae03f43b2e3e3) | fix | replace template line endings with platform specific | + + + + + +# 17.0.8 (2023-12-21) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [6dba26a0b](https://github.com/angular/angular-cli/commit/6dba26a0b33ee867923c4505decd86f183e0e098) | fix | `ng e2e` and `ng lint` prompt requires to hit Enter twice to proceed on Windows | +| [0b48acc4e](https://github.com/angular/angular-cli/commit/0b48acc4eaa15460175368fdc86e3dd8484ed18b) | fix | re-add `-d` alias for `--dry-run` | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [99b026ede](https://github.com/angular/angular-cli/commit/99b026edece990e7f420718fd4967e21db838453) | fix | add missing property "buildTarget" to interface "ServeBuilderOptions" | +| [313004311](https://github.com/angular/angular-cli/commit/3130043114d3321b1304f99a4209d9da14055673) | fix | do not generate standalone component when using `ng generate module` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [cf11cdf6c](https://github.com/angular/angular-cli/commit/cf11cdf6ce7569e2da5fa3bc76e20d19c719ce4c) | fix | add missing tailwind `@screen` directive in matcher | +| [aa6c757d7](https://github.com/angular/angular-cli/commit/aa6c757d701b7f95896c8f1643968ee030b179af) | fix | construct SSR request URL using server resolvedUrls | +| [0662048d4](https://github.com/angular/angular-cli/commit/0662048d4abbcdc36ff74d647bb7d3056dff42a8) | fix | ensure empty optimized Sass stylesheets stay empty | +| [d1923a66d](https://github.com/angular/angular-cli/commit/d1923a66d9d2ab39831ac4cd012fa0d2df66124b) | fix | ensure external dependencies are used by Web Worker bundling | + + + + + +# 16.2.11 (2023-12-21) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ----- | -------------------------------- | +| [e0e011fc4](https://github.com/angular/angular-cli/commit/e0e011fc47f2383f9be0b432066c1438ddab7103) | build | update dependency vite to v4.5.1 | + + + + + +# 17.0.7 (2023-12-13) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------ | +| [3df3e583c](https://github.com/angular/angular-cli/commit/3df3e583c8788511598bbe406012196a2882ee49) | fix | `baseHref` with trailing slash causes server not to be accessible without trailing slash | +| [ef1178188](https://github.com/angular/angular-cli/commit/ef1178188a145a1277197a33a304910e1024c365) | fix | allow vite to serve JavaScript and TypeScript assets | +| [385eb77d2](https://github.com/angular/angular-cli/commit/385eb77d2645a1407dbc7528e90a506f9bb2952f) | fix | cache loading of component resources in JIT mode | +| [4b3af73ac](https://github.com/angular/angular-cli/commit/4b3af73ac934a24dd2b022604bc01f00389d87a1) | fix | ensure browser-esbuild is used in dev server with browser builder and forceEsbuild | +| [d1b27e53e](https://github.com/angular/angular-cli/commit/d1b27e53ed9e23a0c08c13c22fc0b4c00f3998b2) | fix | ensure port 0 uses random port with Vite development server | +| [f2f7d7c70](https://github.com/angular/angular-cli/commit/f2f7d7c7073e5564ddd8a196b6fcaab7db55b110) | fix | file is missing from the TypeScript compilation with JIT | +| [7b8d6cddd](https://github.com/angular/angular-cli/commit/7b8d6cddd0daa637a5fecdea627f4154fafe23fa) | fix | handle updates of an `npm link` library from another workspace when `preserveSymlinks` is `true` | +| [c08c78cb8](https://github.com/angular/angular-cli/commit/c08c78cb8965a52887f697e12633391908a3b434) | fix | inlining of fonts results in jagged fonts for Windows users | +| [930024811](https://github.com/angular/angular-cli/commit/9300248114282a2a425b722482fdf9676b000b94) | fix | retain symlinks to output platform directories on builds | +| [3623fe911](https://github.com/angular/angular-cli/commit/3623fe9118be14eedd1a04351df5e15b3d7a289a) | fix | update ESM loader to work with Node.js 18.19.0 | + + + + + +# 17.0.6 (2023-12-06) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------- | +| [da5d39471](https://github.com/angular/angular-cli/commit/da5d39471751cd92f6c21936aefc1f7157b4973b) | fix | enable TypeScript `skipLibCheck` in new workspace | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [048512874](https://github.com/angular/angular-cli/commit/048512874bf9cc022cc9a8ab70f35fc60d9982f5) | fix | app-shell generation incorrect content when using the application builder | +| [f9e982c44](https://github.com/angular/angular-cli/commit/f9e982c4458fc022d34039b9c082471c7ce29c07) | fix | check namespaced Sass variables when rebasing URLs | +| [a1e8ffa9d](https://github.com/angular/angular-cli/commit/a1e8ffa9df3a8eb6af2a8851385ed8927e3c0c64) | fix | correctly align error/warning messages when spinner is active | +| [46d88a034](https://github.com/angular/angular-cli/commit/46d88a034343dc93dd0c467afc08c824da427fef) | fix | handle watch updates on Mac OSX when using native FSEvents API | +| [4594407ae](https://github.com/angular/angular-cli/commit/4594407ae214ce49985a5df315cae3ac8107147d) | fix | improve file watching on Windows when using certain IDEs | +| [aa9e7c615](https://github.com/angular/angular-cli/commit/aa9e7c615529cb9dd6dccd862674cadac0372f08) | fix | normalize locale tags with Intl API when resolving in application builder | +| [a8dbf1da2](https://github.com/angular/angular-cli/commit/a8dbf1da27faf772a4df382b1301e95c32d1ba89) | fix | watch symlink when using `preserveSymlinks` option | +| [e3820cb6c](https://github.com/angular/angular-cli/commit/e3820cb6c7cf131d890882f9e94b8f23c4cbb6a3) | perf | only enable advanced optimizations with script optimizations | + + + + + +# 17.0.5 (2023-11-29) + +Rolling back [bbbe13d67](https://github.com/angular/angular-cli/commit/bbbe13d6782ba9d1b80473a98ea95bc301c48597) which appears to break file watching on Mac devices. + + + + + +# 17.0.4 (2023-11-29) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [7a2823080](https://github.com/angular/angular-cli/commit/7a2823080c61df3515d85f7aa35ee83f57e80e2d) | fix | remove CommonModule import from standalone components | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [0634a4e40](https://github.com/angular/angular-cli/commit/0634a4e40f1b2e4c0a076814f3e1b242ccf1a588) | fix | avoid native realpath in application builder | +| [22880d9cb](https://github.com/angular/angular-cli/commit/22880d9cbf70fffb6cc685b3a9ad82ca741a56fe) | fix | correct set locale when using esbuild based builders | +| [a0680672f](https://github.com/angular/angular-cli/commit/a0680672fd369dc6fba2433441d086e53bebb0a2) | fix | correctly watch files when app is in a directory that starts with a dot | +| [bbbe13d67](https://github.com/angular/angular-cli/commit/bbbe13d6782ba9d1b80473a98ea95bc301c48597) | fix | improve file watching on Windows when using certain IDEs | +| [27e7c2e1b](https://github.com/angular/angular-cli/commit/27e7c2e1b4f514843c2c505b7fe1b3cef126a101) | fix | propagate localize errors to full build result | +| [7455fdca0](https://github.com/angular/angular-cli/commit/7455fdca01bd4af00248bb1769945dc088c59063) | fix | serve assets from the provided `serve-path` | +| [657a07bd6](https://github.com/angular/angular-cli/commit/657a07bd6ba138a209c2a1540ea4d200c60e0f66) | fix | treeshake unused class that use custom decorators | +| [77474951b](https://github.com/angular/angular-cli/commit/77474951b59605a2c36a8bd890376f9e28131ee4) | fix | use workspace real path when not preserving symlinks | + + + + + +# 17.0.3 (2023-11-21) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [450dd29a1](https://github.com/angular/angular-cli/commit/450dd29a13da9930fede96732b29c9c04e1c0cf5) | fix | default to watching project root on Windows with application builder | +| [8072b8574](https://github.com/angular/angular-cli/commit/8072b8574a84a97277e8c83ebbbdde076b79a910) | fix | ensure service worker hashes index HTML file for application builder | +| [d99870740](https://github.com/angular/angular-cli/commit/d998707406c7a191a191f71d07a9491481c8ad56) | perf | only create one instance of postcss when needed | + + + + + +# 17.0.2 (2023-11-20) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------- | +| [023645185](https://github.com/angular/angular-cli/commit/02364518571a2b73be945a0036bbfa39e336330c) | fix | always normalize AOT file reference tracker paths | +| [3b99980bd](https://github.com/angular/angular-cli/commit/3b99980bd02c875a37d1603ae7468558fe7ef4c3) | fix | emit root files when `localize` is enabled when using the esbuild based builders | +| [ef3e3abb8](https://github.com/angular/angular-cli/commit/ef3e3abb8e29a9274e9d1f5fc5c18f01de6fd76f) | fix | ensure watch file paths from TypeScript are normalized | +| [d11b36fe2](https://github.com/angular/angular-cli/commit/d11b36fe207d8a38cb4a1001667c63ecd17aba0c) | fix | normalize paths in ssr sourcemaps to posix when using vite | +| [62d51383a](https://github.com/angular/angular-cli/commit/62d51383acfd8cdeedf07b34c2d78f505ff2e3a8) | fix | only include vendor sourcemaps when using the dev-server when the option is enabled | +| [d28ba8a73](https://github.com/angular/angular-cli/commit/d28ba8a7311ea3345b112a47d6f1e617fb691643) | fix | remove browser-esbuild usage warning | + + + + + +# 17.0.1 (2023-11-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------- | +| [5267e6055](https://github.com/angular/angular-cli/commit/5267e605567aba798ee00322f14e3a48eae68b48) | fix | handle packages with no version | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [d9f7d439e](https://github.com/angular/angular-cli/commit/d9f7d439eba879f8fffaacd258d832c407dfd90f) | fix | add helper script to spawn SSR server from `dist` | +| [a80926cdb](https://github.com/angular/angular-cli/commit/a80926cdb6b4d99a65549fcfba2ab094a5835480) | fix | html indentation | +| [f7f62c9d6](https://github.com/angular/angular-cli/commit/f7f62c9d6988e6801981592f56137cd02bfe2316) | fix | remove `downlevelIteration` from `tsconfig.json` for new workspaces | +| [7cb57317d](https://github.com/angular/angular-cli/commit/7cb57317d2b78e9a1f947c9f11175a7d381275fc) | fix | use href property binding for links | +| [731917cd0](https://github.com/angular/angular-cli/commit/731917cd00b366bbec4f184ee9064b307eba59ce) | fix | use styleUrl | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [15dd71aba](https://github.com/angular/angular-cli/commit/15dd71abac77ec5e1c092bebb86edffa3999937a) | fix | `deleteOutputPath` when using `esbuild-builder` | +| [fa4d8ff31](https://github.com/angular/angular-cli/commit/fa4d8ff31ef64738e45078c0e7be471591361442) | fix | add actionable error when file replacement is missing | +| [160a91160](https://github.com/angular/angular-cli/commit/160a91160ff3677d9e2d3d413ae360c4e1957c53) | fix | add support for vendor sourcemaps when using the dev-server | +| [5623c193e](https://github.com/angular/angular-cli/commit/5623c193e4cccbf6783f7e3faaf0a6c2fb086b34) | fix | cache stylesheet load errors with application builder | +| [1a5538e0c](https://github.com/angular/angular-cli/commit/1a5538e0c9cc121fa1608eb99e941bc3a5f59ad6) | fix | disable Worker wait loop for TS/NG parallel compilation in web containers | +| [883771946](https://github.com/angular/angular-cli/commit/883771946a36a42ebfe23d32b393513309b16c82) | fix | do not process ssr entry-point when running `ng serve` | +| [d3b549167](https://github.com/angular/angular-cli/commit/d3b54916705e57f017597917d9aea1f71f2ba95a) | fix | empty output directory instead of removing | +| [596f7639a](https://github.com/angular/angular-cli/commit/596f7639a6c7fe00c9088e32739578cc374a31e2) | fix | ensure compilation errors propagate to all bundle actions | +| [d900a5217](https://github.com/angular/angular-cli/commit/d900a5217a75accf434a95ad90300ec5005a23a8) | fix | maintain current watch files after build errors | +| [21549bdeb](https://github.com/angular/angular-cli/commit/21549bdeb97b23f7f37110d579513f3102dc60e8) | fix | prerender default view when no routes are defined | +| [4c251647b](https://github.com/angular/angular-cli/commit/4c251647b8fdb3b128ca3252c83aaa71ecc48e88) | fix | rewire sourcemap back to original source root | + + + + + +# 17.0.0 (2023-11-08) + +## Breaking Changes + +### @schematics/angular + +- Routing is enabled by default for new applications when using `ng generate application` and `ng new`. The `--no-routing` command line option can be used to disable this behaviour. +- `ng g interceptor` now generate a functional interceptor by default. or guard by default. To generate a class-based interceptor the `--no-functional` command flag should be used. +- `rootModuleClassName`, `rootModuleFileName` and `main` options have been removed from the public `pwa` and `app-shell` schematics. +- App-shell and Universal schematics deprecated unused `appId` option has been removed. + +### @angular-devkit/build-angular + +- Node.js v16 support has been removed + + Node.js v16 is planned to be End-of-Life on 2023-09-11. Angular will stop supporting Node.js v16 in Angular v17. + For Node.js release schedule details, please see: https://github.com/nodejs/release#release-schedule + +### @angular-devkit/schematics + +- deprecated `runExternalSchematicAsync` and `runSchematicAsync` methods have been removed in favor of `runExternalSchematic` and `runSchematic`. + +## Deprecations + +### @angular-devkit/build-angular + +- The `browserTarget` in the dev-server and extract-i18n builders have been deprecated in favor of `buildTarget`. + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [f4e7fa873](https://github.com/angular/angular-cli/commit/f4e7fa87350ea1162287114796e0e04e2af101c4) | fix | add `@angular/ssr` as part of the ng update `packageGroup` | +| [1f7156b11](https://github.com/angular/angular-cli/commit/1f7156b112606410ab9ea1cd3f178a762566b96b) | fix | add Node.js 20 as supported version | +| [4b9a87c90](https://github.com/angular/angular-cli/commit/4b9a87c90469481dc3dd0da4d1506521b4203255) | fix | ignore peer mismatch when updating @nguniversal/builders | +| [f66f9cf61](https://github.com/angular/angular-cli/commit/f66f9cf612bed49b961f1f8a8e4deef05fd5ef40) | fix | remove Node.js 16 from supported checks | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------- | +| [741cca73c](https://github.com/angular/angular-cli/commit/741cca73c129ff05e7229081d50762a054c09a8d) | feat | add `ng new --ssr` | +| [3938863b9](https://github.com/angular/angular-cli/commit/3938863b9900fcfe574b3112d73a8f34672f38bd) | feat | add migration to migrate from `@nguniversal` to `@angular/ssr` | +| [dc6b6eaf6](https://github.com/angular/angular-cli/commit/dc6b6eaf6f8af0d2b3f31cea77dc9a63ff845e3c) | feat | add migration to replace usages of `@nguniversal/builders` | +| [6979eba3c](https://github.com/angular/angular-cli/commit/6979eba3c9d46fd5fc2622d28636c48dbcbbe1c6) | feat | enable hydration when adding SSR, SSG or AppShell | +| [1a6a139aa](https://github.com/angular/angular-cli/commit/1a6a139aaf8d5a6947b399bbbd48bbfd9e52372c) | feat | enable routing by default for new applications | +| [ac0db6697](https://github.com/angular/angular-cli/commit/ac0db6697593196692e5b87e1e724be6de0ef0a0) | feat | enable standalone by default in new applications | +| [a189962a5](https://github.com/angular/angular-cli/commit/a189962a515051fd77e20bf8dd1815086a0d12ef) | feat | generate functional interceptors by default | +| [ae45c4ab8](https://github.com/angular/angular-cli/commit/ae45c4ab8103ba8ebc2686e71dbf7d0394b1ee92) | feat | update `ng new` generated application | +| [3f8aa9d8c](https://github.com/angular/angular-cli/commit/3f8aa9d8c7dc7eff06516c04ba08764bb044cb6b) | feat | update` ng new` to use the esbuild application builder based builder | +| [03a1eaf01](https://github.com/angular/angular-cli/commit/03a1eaf01c009d814cb476d2db53b2d0a4d58bcd) | fix | account for new block syntax in starter template | +| [eb0fc7434](https://github.com/angular/angular-cli/commit/eb0fc7434539d3f5a7ea3f3c4e540ac920b10c19) | fix | add missing express `REQUEST` and `RESPONSE` tokens | +| [ecdcff2db](https://github.com/angular/angular-cli/commit/ecdcff2db2b205443a585dd5dd118dbd50613883) | fix | add missing icons in ng-new template | +| [175944672](https://github.com/angular/angular-cli/commit/17594467218b788ebb27d8d16ffb0b555fcf71ee) | fix | do not add unnecessary dependency on `@angular/ssr` during migration | +| [23c4c5e42](https://github.com/angular/angular-cli/commit/23c4c5e4293ef770d555b8b2bd449ad32d1537d4) | fix | enable TypeScript `esModuleInterop` by default for ESM compliance | +| [d60a6e86a](https://github.com/angular/angular-cli/commit/d60a6e86a48f15b3ddf89943dad31ee267f67648) | fix | noop workspace config migration when already executed | +| [e516a4bdb](https://github.com/angular/angular-cli/commit/e516a4bdb7f6bb87f556e58557e57db6f7e65845) | fix | pass `ssr` option to application schematics | +| [419b5c191](https://github.com/angular/angular-cli/commit/419b5c1917c45dc115b107479d5066b9193497fa) | fix | remove `baseUrl` from `tsconfig.json` | +| [0368b23f2](https://github.com/angular/angular-cli/commit/0368b23f2e5d8ca9c6191a2db956dc6850daebfc) | fix | use @types/node v18 | +| [b15e82758](https://github.com/angular/angular-cli/commit/b15e827580d6d3159c49521eb9b5d2b6d8ca2502) | refactor | remove deprecated appId option | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------- | +| [c48982dc1](https://github.com/angular/angular-cli/commit/c48982dc1d01d11be54ffb0b1469e3b0557f3920) | feat | add `buildTarget` option to dev-server and `extract-i18n` builders | +| [1fb0350eb](https://github.com/angular/angular-cli/commit/1fb0350eb7370ef6f72acc4e20c4d0bee8bf0b29) | feat | add initial support for bundle budgets to esbuild builders | +| [8168ae2a8](https://github.com/angular/angular-cli/commit/8168ae2a892dd012707bd294ffd26d0a070c0b5d) | feat | apply global CSS updates without a live-reload when using `vite` | +| [91019bde2](https://github.com/angular/angular-cli/commit/91019bde2af5fb9dff6426ba24098271d8ac4889) | feat | enable localize support for SSR with application builder | +| [3c0719bde](https://github.com/angular/angular-cli/commit/3c0719bde244c45d71881d35899e5ee6206c09ee) | feat | initial i18n extraction support for application builder | +| [8bce80b91](https://github.com/angular/angular-cli/commit/8bce80b91b953c391ef8e45fec7f887f8d8521aa) | feat | initial support for application Web Worker discovery with esbuild | +| [49f07a84d](https://github.com/angular/angular-cli/commit/49f07a84d6f6120388d9fc48a2514d3398986e49) | feat | standardize application builder output structure | +| [c3a87a60e](https://github.com/angular/angular-cli/commit/c3a87a60e0d3cdcae9f4361c2cf21c7ea29bd7de) | feat | support basic web worker bundling with esbuild builders | +| [9e425308a](https://github.com/angular/angular-cli/commit/9e425308a0c146b685e452a106cbdf3e02bddd00) | feat | support component style budgets in esbuild builders | +| [771e036d5](https://github.com/angular/angular-cli/commit/771e036d5ce3d436736d3c8b261050d633b3ef29) | feat | support deploy URL option for `browser-esbuild` builder | +| [c5f3ec71f](https://github.com/angular/angular-cli/commit/c5f3ec71f536e7ebb1c8cd0d7523b42e58f9611a) | feat | support i18n inlining with esbuild-based builder | +| [fd62a9315](https://github.com/angular/angular-cli/commit/fd62a9315defb89b4bea996d256887a6ec7b4327) | feat | support i18n with service worker and app-shell with esbuild builders | +| [5898f72a9](https://github.com/angular/angular-cli/commit/5898f72a97c29d38b9e8b8ca23255f9fbce501e5) | feat | support namedChunks option in application builder | +| [8f9a0d70c](https://github.com/angular/angular-cli/commit/8f9a0d70cdf692b19574410cebb4d029056263fc) | feat | support standalone apps route discovery during prerendering | +| [6b08efa6f](https://github.com/angular/angular-cli/commit/6b08efa6ffd988e08e3db471ffe3214a029de116) | fix | account for arrow function IIFE | +| [2f299fc7b](https://github.com/angular/angular-cli/commit/2f299fc7b5f00056054a06574e65ae311cd3ce0c) | fix | account for styles specified as string literals and styleUrl | +| [9994b2dde](https://github.com/angular/angular-cli/commit/9994b2dde801b2f74fb70152eb73225283da32a3) | fix | add a maximum rendering timeout for SSR and SSG during development | +| [da4e19145](https://github.com/angular/angular-cli/commit/da4e19145b341dccdd5174cc7bc821e5025514b1) | fix | address a path concatenation on Windows | +| [9d4d11cc4](https://github.com/angular/angular-cli/commit/9d4d11cc43f2ae149ee8bfcf28285a1f62594ef7) | fix | allow SSR compilation to work with TS allowJs option | +| [e3c5b91e8](https://github.com/angular/angular-cli/commit/e3c5b91e8a09c8a7dd940655087b69a8949cb2cc) | fix | automatically include known packages in vite prebundling | +| [ca38ee34c](https://github.com/angular/angular-cli/commit/ca38ee34c6267e32b8ee74db815f929896f1baba) | fix | avoid binary content in architect results with browser-esbuild | +| [657f78292](https://github.com/angular/angular-cli/commit/657f78292b4c78db5a43a172087a078820812323) | fix | avoid dev server update analysis when build fails with vite | +| [2c33f09db](https://github.com/angular/angular-cli/commit/2c33f09db0561f344a26dd4f4304a9098e0ee13f) | fix | avoid dev-server proxy rewrite normalization when invalid value | +| [b182be8aa](https://github.com/angular/angular-cli/commit/b182be8aa7ff5fd3cddc0bcac5f4e45e9ed9cf2e) | fix | avoid in-memory prerendering ESM loader errors | +| [0c982b993](https://github.com/angular/angular-cli/commit/0c982b993b69f4a4b52002cc65ad7ba3b0b9d591) | fix | avoid repeat error clear in vite development server | +| [e41e2015b](https://github.com/angular/angular-cli/commit/e41e2015bfc37672fb67014ae38f31b63f0bb256) | fix | avoid spawning workers when there are no routes to prerender | +| [2d2e79921](https://github.com/angular/angular-cli/commit/2d2e79921a72c4fafad673abe501ba10400403d2) | fix | clean up internal Angular state during rendering SSR | +| [83020fc32](https://github.com/angular/angular-cli/commit/83020fc3291715802c28c5f7dcf7a261bc7f32cd) | fix | clear diagnostic cache when external templates change with esbuild builders | +| [c12f98f94](https://github.com/angular/angular-cli/commit/c12f98f948b1c10594f9d00f4ebf87630fe3cc47) | fix | conditionally enable deprecated Less stylesheet JavaScript support | +| [e10f49efa](https://github.com/angular/angular-cli/commit/e10f49efa8ac96e72bbc441423a730fd172c9f1d) | fix | convert AOT compiler exceptions into diagnostics | +| [667f43af6](https://github.com/angular/angular-cli/commit/667f43af6d91025424147f6e9ac94800f463da1d) | fix | correctly resolve polyfills when `baseUrl` URL is not set to root | +| [d46fb128a](https://github.com/angular/angular-cli/commit/d46fb128a51f172da72ab403ec97213099f43de8) | fix | disable dependency optimization for SSR | +| [1b384308c](https://github.com/angular/angular-cli/commit/1b384308c65ff67b8eac7f3b6407e19ce3db46fa) | fix | disable parallel TS/NG compilation inside WebContainers | +| [070da72c4](https://github.com/angular/angular-cli/commit/070da72c481b881538d6f5ff39955a3da7eb5126) | fix | do not perform advanced optimizations on `@angular/common/locales/global` | +| [508c7606e](https://github.com/angular/angular-cli/commit/508c7606ea2fa8e84d5243992abb59db1b75af49) | fix | do not print `Angular is running in development mode.` in the server console when running prerender in dev mode | +| [e817656f6](https://github.com/angular/angular-cli/commit/e817656f601eaaf910271d5bb2c2230ddb8ed864) | fix | do not print `Angular is running in development mode.` in the server console when running prerender in dev mode | +| [f806e3498](https://github.com/angular/angular-cli/commit/f806e3498b5a4fced7a515258fad30821f3e866c) | fix | elide setClassDebugInfo calls | +| [188a00f3e](https://github.com/angular/angular-cli/commit/188a00f3e466c6c31c7671c63ffc91ccda4590c9) | fix | elide setClassMetadataAsync calls | +| [05ce9d697](https://github.com/angular/angular-cli/commit/05ce9d697a723dcac7a5d24a14f4d11f8686851a) | fix | ensure all SSR chunks are resolved correctly with dev server | +| [d392d653c](https://github.com/angular/angular-cli/commit/d392d653cba67db28eddd003dfec6dcb9b192a95) | fix | ensure correct web worker URL resolution in vite dev server | +| [1a6aa4378](https://github.com/angular/angular-cli/commit/1a6aa437887d2fc5d08c833efc0ca792f6157350) | fix | ensure css url() prefix warnings support Sass rebasing | +| [52f595655](https://github.com/angular/angular-cli/commit/52f595655c69bb6a1398b030cf937b0d92d49864) | fix | ensure i18n locale data is included in SSR application builds | +| [3ad028bb4](https://github.com/angular/angular-cli/commit/3ad028bb442a8978a4f45511cab9bb515764b930) | fix | ensure localize polyfill and locale specifier are injected when not inlining | +| [3e5a99c2c](https://github.com/angular/angular-cli/commit/3e5a99c2c438152a0b930864dcad660a6ea1590a) | fix | ensure recalculation of component diagnostics when template changes | +| [fa234a418](https://github.com/angular/angular-cli/commit/fa234a4186c9d408bfb52b3a649d307f93d0b9b3) | fix | ensure secondary Angular compilations are unblocked on start errors | +| [c0c7dad77](https://github.com/angular/angular-cli/commit/c0c7dad77dd59a387dbcc643a52ee1ed634727ab) | fix | ensure that externalMetadata is defined | +| [ac7caa426](https://github.com/angular/angular-cli/commit/ac7caa4264c7a68467903528deca4a6f579ee15c) | fix | ensure unique internal identifiers for inline stylesheet bundling | +| [1f73bcc49](https://github.com/angular/angular-cli/commit/1f73bcc49abd9f136a18dc6329e2f50a7565eb76) | fix | ensure Web Worker code file is replaced in esbuild builders | +| [23a722b79](https://github.com/angular/angular-cli/commit/23a722b791a64bae32dc925160f2c3d1942955fc) | fix | exclude node.js built-ins from vite dependency optimization | +| [fd2c4c324](https://github.com/angular/angular-cli/commit/fd2c4c324dcfedc81af41351b52ed4c8e41f48fc) | fix | expose ssr-dev-server builder in the public api | +| [9eb58cf7a](https://github.com/angular/angular-cli/commit/9eb58cf7a51c0b7950f80b474890fb2ebd685977) | fix | fail build on non bundling error when using the esbuild based builders | +| [a3e9efe80](https://github.com/angular/angular-cli/commit/a3e9efe80f6e77c8bf80f6a2d37f4488f780503b) | fix | fully track Web Worker file changes in watch mode | +| [b9505ed09](https://github.com/angular/angular-cli/commit/b9505ed097d60eadae665d4664199e3d4989c864) | fix | generate a file containing a list of prerendered routes | +| [192a2ae6b](https://github.com/angular/angular-cli/commit/192a2ae6bd8bdeab785f1ed8e60c5e4213801dd3) | fix | handle HTTP requests to assets during prerendering | +| [19191e32b](https://github.com/angular/angular-cli/commit/19191e32bab9a2927b4feb5074e14165597fbf6d) | fix | handle HTTP requests to assets during SSG in dev-server | +| [8981d8c35](https://github.com/angular/angular-cli/commit/8981d8c355ec9154fcdcdad3a66e1b789d1079b0) | fix | improve sharing of TypeScript compilation state between various esbuild instances during rebuilds | +| [5a3ae0159](https://github.com/angular/angular-cli/commit/5a3ae0159faa81558537012a0ceba07b5ad1b88b) | fix | in vite skip SSR middleware for path with extensions | +| [f87f22d3f](https://github.com/angular/angular-cli/commit/f87f22d3f1436678ca1e07cc10874a012ae55e60) | fix | keep dependencies pre-bundling validate between builds | +| [0da87bf1c](https://github.com/angular/angular-cli/commit/0da87bf1c94c6caf711204fcdd9a3973d766bd6e) | fix | limit concurrent output file writes with application builder | +| [391ff78cb](https://github.com/angular/angular-cli/commit/391ff78cb0f29212c476ca36940b77839b84075e) | fix | log number of prerendered routes in console | +| [c46f312ad](https://github.com/angular/angular-cli/commit/c46f312adb06ae4a8293a07aa441514030052e93) | fix | media files download files in vite | +| [87425a791](https://github.com/angular/angular-cli/commit/87425a791fbdb44b3504e7e6d4b000b1df92c494) | fix | normalize paths when invalidating stylesheet bundler | +| [d4f37da50](https://github.com/angular/angular-cli/commit/d4f37da50ce2890a2b86281e5a373beab349b630) | fix | only show changed output files in watch mode with esbuild | +| [0d54f2d20](https://github.com/angular/angular-cli/commit/0d54f2d20bfd6d55615c0ab3537b5af0aeb008ee) | fix | only watch used files with application builder | +| [1f299ff2d](https://github.com/angular/angular-cli/commit/1f299ff2de3c80bf9cb3dc4b6a5ff02e81c1a94f) | fix | prebundle dependencies for SSR when using Vite | +| [58bd3971f](https://github.com/angular/angular-cli/commit/58bd3971fd2a95a5da1a87deddfe2416f3d636d6) | fix | process nested tailwind usage in application builder | +| [60ca3c82d](https://github.com/angular/angular-cli/commit/60ca3c82d28d0168b2f608a44a701ad8ad658369) | fix | provide server baseUrl result property in Vite-based dev server | +| [0c20cc4dc](https://github.com/angular/angular-cli/commit/0c20cc4dc5fe64221533d0a4cbe9d907881c85ae) | fix | re-add TestBed compileComponents in schematics to support defer block testing | +| [9453a2380](https://github.com/angular/angular-cli/commit/9453a23800f40a33b16fd887e3aa0817448134b1) | fix | remove CJS usage warnings for inactionable packages | +| [5bf7022c4](https://github.com/angular/angular-cli/commit/5bf7022c4749f1298de61ef75e36769bbb8aba12) | fix | remove support for Node.js v16 | +| [c27ad719f](https://github.com/angular/angular-cli/commit/c27ad719f2cb1b13f76f8fce033087a9124e646d) | fix | remove unactionable error overlay suggestion from Vite-based dev server | +| [263271fae](https://github.com/angular/angular-cli/commit/263271fae3f664da9d396192152d22a9b6e3ef09) | fix | resolve and load sourcemaps during prerendering to provide better stacktraces | +| [651e3195f](https://github.com/angular/angular-cli/commit/651e3195ffe06394212c8d8d275289ac05ea5ef5) | fix | resolve and load sourcemaps when using vite dev server with prerendering and ssr | +| [b78508fc8](https://github.com/angular/angular-cli/commit/b78508fc80bb9b2a3aec9830ad3ae9903d25927b) | fix | several fixes to assets and files writes in browser-esbuild builder | +| [c4c299bce](https://github.com/angular/angular-cli/commit/c4c299bce900b27556eaf2e06838a52f16990bb6) | fix | silence xhr2 not ESM module warning | +| [f7f6e97d0](https://github.com/angular/angular-cli/commit/f7f6e97d0f3540badb68813c39ce0237e4dcc9e3) | fix | skip checking CommonJS module descendants | +| [c11a0f0d3](https://github.com/angular/angular-cli/commit/c11a0f0d36f6cbffdf0464135510bda454efb08b) | fix | support custom index option paths in Vite-based dev server | +| [6c3d7d1c1](https://github.com/angular/angular-cli/commit/6c3d7d1c10907d8d57b5f84f298b324d6f972226) | fix | update `ssr` option definition | +| [4e89c3cae](https://github.com/angular/angular-cli/commit/4e89c3cae43870a10ef58de5ebdc094f5a06023e) | fix | use a dash in bundle names | +| [83b4b2567](https://github.com/angular/angular-cli/commit/83b4b25678ba6b8082d580a2d75b0f02a9addc2a) | fix | use browserslist when processing global scripts in application builder | +| [ca4d1634f](https://github.com/angular/angular-cli/commit/ca4d1634f7fa2070f53f5978387ea68cc875c986) | fix | use component style load result caching information for file watching | +| [34947fc64](https://github.com/angular/angular-cli/commit/34947fc64953f845d33ffb1c52f236869a040c9d) | fix | use incremental component style bundling only in watch mode | +| [ec160fe4e](https://github.com/angular/angular-cli/commit/ec160fe4e89cb89b93278cfac63877093dd19392) | fix | warn if using partial mode with application builder | +| [559e89159](https://github.com/angular/angular-cli/commit/559e89159150a10728272081b7bbda80fe708093) | fix | Windows Node.js 20 prerendering failure ([#26186](https://github.com/angular/angular-cli/pull/26186)) | +| [2cbec36c7](https://github.com/angular/angular-cli/commit/2cbec36c7286cdbbbd547433061421d7fe7762cc) | perf | cache polyfills virtual module result | +| [e06e95f73](https://github.com/angular/angular-cli/commit/e06e95f73a35e2cc7cb00a44ce3633b4c99c8505) | perf | conditionally add Angular compiler plugin to polyfills bundling | +| [61f409cbe](https://github.com/angular/angular-cli/commit/61f409cbe4a7bf59711ef0cfa3b7365a8df3016d) | perf | disable ahead of time prerendering in vite dev-server | +| [01ab16c5d](https://github.com/angular/angular-cli/commit/01ab16c5d5678a135a5af5640ad2ba7c33a00452) | perf | fully avoid rebuild of component stylesheets when unchanged | +| [99d9037ee](https://github.com/angular/angular-cli/commit/99d9037eee2eabd7b5ec2d8f01146578ef6b5860) | perf | only perform a server build when either prerendering, app-shell or ssr is enabled | +| [c013a95e2](https://github.com/angular/angular-cli/commit/c013a95e2f38a5c2435b22c3338bf57b03c84ebf) | perf | only rebundle browser polyfills on explicit changes | +| [e68a662bc](https://github.com/angular/angular-cli/commit/e68a662bc0e636082e43b4f3c894585174366f4d) | perf | only rebundle global scripts/styles on explicit changes | +| [28d9ab88f](https://github.com/angular/angular-cli/commit/28d9ab88fe81898ec7591608816c77455c9a61bf) | perf | only rebundle server polyfills on explicit changes | +| [6d3942723](https://github.com/angular/angular-cli/commit/6d3942723d824382e52a8f06e03dcbc3d6d8eff6) | perf | optimize server or browser only dependencies once | +| [2e8e9d802](https://github.com/angular/angular-cli/commit/2e8e9d8020aa01107a3ee6b31942d9d53d6f73cd) | perf | patch `fetch` to load assets from memory | +| [49fe74e24](https://github.com/angular/angular-cli/commit/49fe74e241d75456c65a7cd439b9eb8842e9d6d7) | perf | reduce CLI loading times by removing critters from critical path | +| [07e2120da](https://github.com/angular/angular-cli/commit/07e2120dab741fda11debc0fe777a5ef888dcaad) | perf | remove JavaScript transformer from server polyfills bundling | +| [c28475d30](https://github.com/angular/angular-cli/commit/c28475d30b08138ddddb9903acaa067cf8ab2ef6) | perf | reuse esbuild generated output file hashes | +| [59c22aa4c](https://github.com/angular/angular-cli/commit/59c22aa4cadd7bc6da20acfd3632c834824044e2) | perf | start SSR dependencies optimization before the first request | +| [223a82f5f](https://github.com/angular/angular-cli/commit/223a82f5f02c8caaf34ce49ee3ddde22a75e65c1) | perf | use incremental bundling for component styles in esbuild builders | +| [4b67d2afd](https://github.com/angular/angular-cli/commit/4b67d2afd3a2d4be188a7313b3fe4ea5c07907b6) | perf | use single JS transformer instance during dev-server prebundling | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------- | +| [f600bbc97](https://github.com/angular/angular-cli/commit/f600bbc97d30a003b9d41fa5f67590d3955e6375) | refactor | remove deprecated `runExternalSchematicAsync` and `runSchematicAsync` | + +### @angular/pwa + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------- | +| [81e4917ce](https://github.com/angular/angular-cli/commit/81e4917ceca89759770a76d63b932f380d83685c) | fix | replace Angular logos | + +### @angular/ssr + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [dcf3fddff](https://github.com/angular/angular-cli/commit/dcf3fddff2fa4cf3433c5d726be9f514ba41e827) | feat | add performance profiler to `CommonEngine` | +| [6224b0599](https://github.com/angular/angular-cli/commit/6224b0599fd60f61c537aa602fb89079197a6e2d) | fix | correctly set config URL | +| [8d033841d](https://github.com/angular/angular-cli/commit/8d033841d1785944f60ccd425e413865c9caf581) | fix | enable `prerender` and `ssr` for all build configuration | +| [ee0991bed](https://github.com/angular/angular-cli/commit/ee0991beddc96160f9ba7e27b29def54868f3490) | fix | enable performance profiler option name | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [f43754570](https://github.com/angular/angular-cli/commit/f437545705d41c781498b8e7724293455cf3edf9) | feat | add automated preconnects for image domains | +| [4fe03266a](https://github.com/angular/angular-cli/commit/4fe03266a9232346ec49defa98d9eb3a8d88b1ff) | fix | account for arrow function IIFE | +| [828030da0](https://github.com/angular/angular-cli/commit/828030da0fa9e82fa784c4f55e3c089c7c601e98) | fix | account for styles specified as string literals and styleUrl | +| [16428fc97](https://github.com/angular/angular-cli/commit/16428fc97ae64627f790346e6b54b94a67c7202c) | fix | adjust static scan to find image domains in standlone components | +| [486becdbb](https://github.com/angular/angular-cli/commit/486becdbbaec7cacfa42bd66882efe720473b0f6) | fix | remove setClassDebugInfo calls | +| [89f21ac8c](https://github.com/angular/angular-cli/commit/89f21ac8c4309614a59cda5a8ebc3b3fbc663932) | fix | remove setClassMetadataAsync calls | +| [8899fb9e3](https://github.com/angular/angular-cli/commit/8899fb9e36556debe3b262f27c1b6e94c4963144) | fix | skip transforming empty inline styles in Webpack JIT compilations | + + + + + +# 16.2.10 (2023-11-08) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------- | +| [bab3672cd](https://github.com/angular/angular-cli/commit/bab3672cdaf4875cf83f94e34abdef29cffe2686) | fix | normalize exclude path | + + + + + +# 16.2.8 (2023-10-25) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------ | +| [44275601b](https://github.com/angular/angular-cli/commit/44275601ba0e4c7b8c24f8184a33d09350a0fbef) | fix | remove the need to specify `--migrate-only` when `--name` is used during `ng update` | + + + + + +# 16.2.7 (2023-10-19) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------ | +| [f1a0c3361](https://github.com/angular/angular-cli/commit/f1a0c3361a6caa27bdf5cc07315d8bf2b6424b11) | fix | change Twitter logo to X | + + + + + +# 16.2.6 (2023-10-11) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------- | +| [c6ea25626](https://github.com/angular/angular-cli/commit/c6ea2562683cc6e640136a02760db9363ded4352) | fix | fully downlevel async/await when using vite dev-server with caching enabled | + + + + + +# 15.2.10 (2023-10-05) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------ | +| [05213c95b](https://github.com/angular/angular-cli/commit/05213c95b032dd64fdc73ed33af695e9f19b5d09) | fix | update dependency postcss to v8.4.31 | + + + + + +# 14.2.13 (2023-10-05) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------ | +| [1ca44dcd9](https://github.com/angular/angular-cli/commit/1ca44dcd9d79916db70180da37b962c2672a76a8) | fix | update dependency postcss to v8.4.31 | + + + + + +# 16.2.5 (2023-10-04) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------- | +| [933358186](https://github.com/angular/angular-cli/commit/93335818689a67557942ab27ec8cc5b96f2a5abe) | fix | do not print `Angular is running in development mode.` in the server console when using dev-server | +| [493bd3906](https://github.com/angular/angular-cli/commit/493bd390679889359a05b2f23b74787647aee341) | fix | update dependency postcss to v8.4.31 | + + + + + +# 16.2.4 (2023-09-27) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [5dc7fb1a1](https://github.com/angular/angular-cli/commit/5dc7fb1a1849a427ceedb06404346de370c91083) | fix | update `@angular/cli` version specifier to use `^` | + + + + + +# 16.2.3 (2023-09-20) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [39643bee1](https://github.com/angular/angular-cli/commit/39643bee1522e0313be612b564f2b96ec45007ec) | fix | correctly re-point RXJS to ESM on Windows | +| [d8d116b31](https://github.com/angular/angular-cli/commit/d8d116b318377d51f258a1a23025be2d41136ee3) | fix | several windows fixes to application builder prerendering | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------- | +| [f1195d035](https://github.com/angular/angular-cli/commit/f1195d0351540bdcc7d3f3e7cf0761389eb3d569) | fix | fix recursion in webpack resolve | + + + + + +# 16.2.2 (2023-09-13) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [e3a40a49a](https://github.com/angular/angular-cli/commit/e3a40a49aa768c6b0ddce24ad47c3ba50028963c) | fix | support dev server proxy pathRewrite field in Vite-based server | + + + + + +# 16.2.1 (2023-08-30) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [221ab2483](https://github.com/angular/angular-cli/commit/221ab2483a5504b0ad864a18dc5a4dbeb8c0748e) | fix | display warning when using `resourcesOutputPath` with esbuild builder | +| [fe752ad87](https://github.com/angular/angular-cli/commit/fe752ad87b8588e2a1ee1611953b36d5c004e673) | fix | encode Sass package resolve directories in importer URLs | +| [82b0f94fd](https://github.com/angular/angular-cli/commit/82b0f94fdacc5f4665d00eeb1c93fcfc104b0cc8) | fix | handle HMR updates of global CSS when using Vite | +| [6a48a11b8](https://github.com/angular/angular-cli/commit/6a48a11b8c218796e4b778bd00d453fc0ac0c48e) | fix | update vite to be able to serve app-shell and SSG pages | +| [fdb16f7cd](https://github.com/angular/angular-cli/commit/fdb16f7cd4327980436ddb1ce190c67c86588d2d) | fix | use correct type for `extraEntryPoints` | + + + + + +# 16.2.0 (2023-08-09) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [e6b377436](https://github.com/angular/angular-cli/commit/e6b377436a471073657dc35e7c7a28db6688760a) | feat | add `ssr` option in application builder | +| [c05c83be7](https://github.com/angular/angular-cli/commit/c05c83be7c6c8bcdad4be8686a6e0701a55304cc) | feat | add initial application builder implementation | +| [095f5aba6](https://github.com/angular/angular-cli/commit/095f5aba60a4c1267a87b8b3ae38dbfbf70731f1) | feat | add initial support for server bundle generation using esbuild | +| [cb165a75d](https://github.com/angular/angular-cli/commit/cb165a75dc8c21ead537684a092ed50d3736e04a) | feat | add pre-rendering (SSG) and App-shell support generation to application builder | +| [2a3fc6846](https://github.com/angular/angular-cli/commit/2a3fc68460152a48758b9353bff48193641861c5) | feat | add preload hints based on transitive initial files | +| [099cec758](https://github.com/angular/angular-cli/commit/099cec758ad671c7fd0ca2058a271e4fe181a44d) | feat | add support for serving SSR with dev-server when using the application builder | +| [449e21b3a](https://github.com/angular/angular-cli/commit/449e21b3a6da990a5865bb5bdfb8145794f40cf9) | fix | correctly load dev server assets with vite 4.4.0+ | +| [f42f10135](https://github.com/angular/angular-cli/commit/f42f10135c1e2184a9080b726dc5e41669937b13) | fix | ensure preload hints for external stylesheets are marked as styles | +| [7defb3635](https://github.com/angular/angular-cli/commit/7defb3635c89737d151c9537bd7becd463098434) | fix | ensure that server dependencies are loaded also in ssr entrypoint | +| [05f31bd28](https://github.com/angular/angular-cli/commit/05f31bd28f002a232598e0468dc418f99e434ae0) | fix | prevent race condition in setting up sass worker pool | +| [5048f6e82](https://github.com/angular/angular-cli/commit/5048f6e82e299b0733f34cbdcb1e7b1ef9a63210) | fix | Set chunk names explicitly | +| [974748cdf](https://github.com/angular/angular-cli/commit/974748cdf894c5ad0451e3fdf1c186bdad80878b) | perf | filter postcss usage based on content in esbuild builder | +| [61a652d91](https://github.com/angular/angular-cli/commit/61a652d91274f4adce20182e630fe9963b4ceddd) | perf | inject Sass import/use directive importer information when resolving | +| [a0a2c7aef](https://github.com/angular/angular-cli/commit/a0a2c7aef675f8aae294d2119f721c4345d633b0) | perf | only load browserslist in babel preset if needed | +| [6bfd1800e](https://github.com/angular/angular-cli/commit/6bfd1800efa2bf41126696b66938bdf291ad5455) | perf | use in-memory Sass module resolution cache | + + + + + +# 16.1.8 (2023-08-04) + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------- | +| [7a420d338](https://github.com/angular/angular-cli/commit/7a420d3382b21d24c73b722e849f01b0aacfb860) | fix | build: update critters | + + + + + +# 16.1.7 (2023-08-02) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [1dab4ed87](https://github.com/angular/angular-cli/commit/1dab4ed8738b42d6b93298889caf1546b011706f) | fix | hot update filename suffix with `.mjs` | + + + + + +# 16.1.6 (2023-07-26) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [20816b57f](https://github.com/angular/angular-cli/commit/20816b57f16b0bcbd5b81f06f6f790e4485c1daa) | fix | error during critical CSS inlining for external stylesheets | + + + + + +# 16.1.5 (2023-07-20) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------- | +| [7e91d4709](https://github.com/angular/angular-cli/commit/7e91d4709966c592c271ff8d3456ce569156e2e5) | fix | add `zone.js` to `ng version` output | +| [475506822](https://github.com/angular/angular-cli/commit/475506822b148c8e2597c60653238a40140bacb0) | fix | throw an error when executed in a google3-context | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [07d3d8c6a](https://github.com/angular/angular-cli/commit/07d3d8c6ae01212de866fac769ff2da888d5adea) | fix | correctly wrap CommonJS exported enums when optimizing | + + + + + +# 16.1.4 (2023-07-06) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [7016cee57](https://github.com/angular/angular-cli/commit/7016cee5783b2762d550db8f2a4f29e7b56f317f) | fix | normalize paths in loader cache with esbuild | + + + + + +# 16.1.3 (2023-06-29) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [b56ab0798](https://github.com/angular/angular-cli/commit/b56ab07980c5d990606ddb1e298fc1c4ecf8a2a8) | fix | use absolute watch paths for postcss dependency messages | + + + + + +# 15.2.9 (2023-06-28) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [f36e38a91](https://github.com/angular/angular-cli/commit/f36e38a913b454ec340d6bf2311391c5df1cee24) | fix | update direct semver dependencies to 7.5.3 | + + + + + +# 16.1.2 (2023-06-28) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [3475e0281](https://github.com/angular/angular-cli/commit/3475e0281da3298f288a5f8836155c0b8c971372) | fix | update direct `semver` dependencies to 7.5.3 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [8108b8c2d](https://github.com/angular/angular-cli/commit/8108b8c2da3ebfdb74f0f9d3554df01f484670bd) | fix | allow linker JIT support with prebundling with esbuild builder | +| [502365037](https://github.com/angular/angular-cli/commit/502365037bf7dbacd0e28d29a074a246971848ea) | fix | use all style language watch files in esbuild builder | + + + + + +# 14.2.12 (2023-06-28) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [bd396b656](https://github.com/angular/angular-cli/commit/bd396b65623fb0b8e826be13f88709e87b54336e) | fix | update direct semver dependencies to 7.5.3 | + + + + + +# 16.1.1 (2023-06-22) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------ | +| [f017fee2e](https://github.com/angular/angular-cli/commit/f017fee2e93a4207b7bfd69c838991546b398753) | fix | actually disable Vite prebundling file discovery | +| [2b4beaca2](https://github.com/angular/angular-cli/commit/2b4beaca2c32c11508078e082b3338d1edb414a0) | fix | experimental esbuild pipeline, add `es2015` to main fields for RxJS v6 compatibility | +| [e3c85e00e](https://github.com/angular/angular-cli/commit/e3c85e00e6b3390f239aaeb3db6a38fe4b4d2523) | fix | track postcss provided file dependencies in esbuild builder | +| [1419fff88](https://github.com/angular/angular-cli/commit/1419fff887173e331690fb0a664a081154842554) | fix | unpin and downgrade `browserslist` | +| [950a4b60f](https://github.com/angular/angular-cli/commit/950a4b60f046117867755ccd005f0e04bcc403a7) | fix | watch all bundler provided inputs with esbuild builder | + + + + + +# 16.1.0 (2023-06-13) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [b14b95990](https://github.com/angular/angular-cli/commit/b14b959901d5a670da0df45e082b8fd4c3392d14) | feat | add bootstrap-agnostic utilities for writing ng-add schematics | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [3ede1a2ca](https://github.com/angular/angular-cli/commit/3ede1a2cac5005f4dfbd2a62ef528a34c3793b78) | feat | allow forcing esbuild builder with dev-server | +| [2d141fe3b](https://github.com/angular/angular-cli/commit/2d141fe3bc1efb9e254b15ce91ebc885a43c928a) | feat | show estimated transfer size with esbuild builder | +| [9aa9b5264](https://github.com/angular/angular-cli/commit/9aa9b5264eee1b1dda7abd334b560d4b446c4970) | feat | support autoprefixer/tailwind CSS with Less/Sass in esbuild builder | +| [3d1c09b23](https://github.com/angular/angular-cli/commit/3d1c09b235bf1db0d031c36fdc68ab99359b34b1) | feat | support dev-server package prebundling with esbuild builder | +| [d8930facc](https://github.com/angular/angular-cli/commit/d8930facc075e39d82b3c6cb252c9a8b5fa6a476) | feat | support incremental TypeScript semantic diagnostics in esbuild builder | +| [5cacd34a2](https://github.com/angular/angular-cli/commit/5cacd34a222eea16c18caa63dbe4448b81e106f3) | fix | watch all TypeScript referenced files in esbuild builder | +| [8336ad80d](https://github.com/angular/angular-cli/commit/8336ad80da41cde69343960f7515d9ffd5e5e2e1) | perf | enable in-memory load result caching for stylesheets in esbuild builder | + + + + + +# 16.0.6 (2023-06-13) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [eebb54cbf](https://github.com/angular/angular-cli/commit/eebb54cbf4683b6113eb56dba17fab038318c918) | fix | correctly handle sass imports | +| [081b62539](https://github.com/angular/angular-cli/commit/081b62539b2562bff130343558bf4baafed7c36d) | fix | support proxy configuration array-form in esbuild builder | + + + + + +# 16.0.5 (2023-06-07) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [9817b984b](https://github.com/angular/angular-cli/commit/9817b984b15e352caedac6e347cc662117b9e0f8) | fix | ignore .git folder in browser-esbuild watcher | +| [ce95d2545](https://github.com/angular/angular-cli/commit/ce95d254510ffa93a9bd4230f6447530d511ef5f) | fix | ignore folders starting with a dot in browser-esbuild watcher | + + + + + +# 16.0.4 (2023-06-01) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [5bff97d5b](https://github.com/angular/angular-cli/commit/5bff97d5b965373cd7e4dc0b731c24d80b512faa) | fix | correctly set overridden compiler option | +| [cd0247514](https://github.com/angular/angular-cli/commit/cd0247514db295661d319bec33ad7167229d99f9) | fix | preemptively remove AOT metadata in esbuild builder | + + + + + +# 16.0.3 (2023-05-25) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [1d83bb656](https://github.com/angular/angular-cli/commit/1d83bb6565550107ab00de52b706cad8f28514b3) | fix | percent encode asset URLs in development server for esbuild | + + + + + +# 16.0.2 (2023-05-17) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [7a3c895c8](https://github.com/angular/angular-cli/commit/7a3c895c8da534ceff26754ca7ffd49b30c24069) | fix | attempt relative global script read first in esbuild builder | +| [f30be2518](https://github.com/angular/angular-cli/commit/f30be2518b118106f5d6634c92279adcefab0f70) | fix | correctly generate serviceworker hashes for binary assets | +| [117e8d001](https://github.com/angular/angular-cli/commit/117e8d00192d3b764c9c362c2554fa80706946cf) | fix | normalize Vite dev-server Windows asset paths | +| [e5c1d43de](https://github.com/angular/angular-cli/commit/e5c1d43de932daedfaac002ff363ed12243f97bb) | perf | minor sourcemap ignorelist improvements for esbuild builder | + + + + + +# 16.0.1 (2023-05-10) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [ed82c83fe](https://github.com/angular/angular-cli/commit/ed82c83fef1a67b4168be455b119860217267564) | fix | avoid CommonJS warnings for relative imports with esbuild builders | +| [3083c4eda](https://github.com/angular/angular-cli/commit/3083c4eda87e735a4b1b9e16ff1f61abbccb1c98) | fix | avoid hash filenames for non-injected global styles/scripts | +| [b106bc9d0](https://github.com/angular/angular-cli/commit/b106bc9d07b1e2e38176c484d2fc04251e274aa5) | fix | clean incoming index URL before processing in esbuild builder | +| [2967705ed](https://github.com/angular/angular-cli/commit/2967705ed3f88c35e93866bca659222769664c62) | fix | convert dev-server glob proxy entries for esbuild builder | +| [a9d20015c](https://github.com/angular/angular-cli/commit/a9d20015c943e89b6f29a6e3e295bef6e2072a92) | fix | disable runtime errors from being displayed in overlay | +| [822b552f6](https://github.com/angular/angular-cli/commit/822b552f6f94ac1c39405f7359550e1ab5aa4c17) | fix | fix index option const value for browser-esbuild | +| [131cd23b6](https://github.com/angular/angular-cli/commit/131cd23b65c12ba671088aafcaff4d522f402ba8) | fix | prevent relative import failure with Less in esbuild builder | +| [fedcc5d92](https://github.com/angular/angular-cli/commit/fedcc5d923b7237622b1e7adef053a2ee68f872e) | fix | properly set base dev-server path with esbuild | +| [cb3161045](https://github.com/angular/angular-cli/commit/cb3161045ef39e335460672d016cf0a973de428a) | fix | show error note for CSS url() tilde usage in esbuild builder | +| [54e5000ca](https://github.com/angular/angular-cli/commit/54e5000ca88655bf9d01b87e317dc5810a7ac676) | fix | workaround for esbuild static block AOT generated code | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [5a35970af](https://github.com/angular/angular-cli/commit/5a35970afdf39461592bb0130eb9b959272949fb) | fix | do not generate an UpdateBuffer for created and overridden files | + +### @angular/pwa + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------ | +| [70d224ca7](https://github.com/angular/angular-cli/commit/70d224ca7edbfe31fb6360e55cbe06c65dc5e91a) | fix | compress PWA icons | + + + + + +# 16.0.0 (2023-05-03) + +## Breaking Changes + +### @angular/cli + +- The deprecated `defaultCollection` workspace option has been removed. Use `schematicCollections` instead. + + Before + + ```json + "defaultCollection": "@angular/material" + ``` + + After + + ```json + "schematicCollections": ["@angular/material"] + ``` + +- The deprecated `defaultProject` workspace option has been removed. The project to use will be determined from the current working directory. +- Node.js v14 support has been removed + + Node.js v14 is planned to be End-of-Life on 2023-04-30. Angular will stop supporting Node.js v14 in Angular v16. + Angular v16 will continue to officially support Node.js versions v16 and v18. + +### @schematics/angular + +- `ng g resolver` and `ng g guard` now generate a functional resolver or guard by default. It is still possible to generate a (deprecated) class-based resolver or guard by using `ng g resolver --no-functional` or `ng g guard --no-functional`. +- The CLI no longer allows to generate `CanLoad` guards. Use `CanMatch` instead. + +### + +- - TypeScript 4.8 is no longer supported. + +### @angular-devkit/build-angular + +- Deprecated `outputPath` and `outputPaths` from the server and browser builder have been removed from the builder output. Use `outputs` instead. + + Note: this change does not effect application developers. + +### @angular-devkit/core + +- Several changes to the `SchemaRegistry`. + - `compile` method now returns a `Promise`. + - Deprecated `flatten` has been removed without replacement. +- - `ContentHasMutatedException`, `InvalidUpdateRecordException`, `UnimplementedException` and `MergeConflictException` API from `@angular-devkit/core` have been removed in favor of the API from `@angular-devkit/schematics`. + - `UnsupportedPlatformException` - A custom error exception should be created instead. + +### @angular-devkit/schematics + +- The depracated `UpdateBuffer` has been removed and `UpdateBuffer2` + is renamed to `UpdateBuffer`. With this change the related and + deprecated symbols `ContentCannotBeRemovedException` and `Chunk` + have also been removed. + +### @ngtools/webpack + +- NGCC integration has been removed and as a result Angular View Engine libraries will no longer work. + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [c2d2da41b](https://github.com/angular/angular-cli/commit/c2d2da41b15143e11f597192eef755c5e3fb4c5d) | feat | add support to add service worker to standalone application | +| [22fdd7da9](https://github.com/angular/angular-cli/commit/22fdd7da97c832048410ca89622712d097490c5d) | feat | generate functional resolvers and guards by default | +| [a832c2028](https://github.com/angular/angular-cli/commit/a832c202828a1caa425e1a0c5ff8d2ebb77c4667) | feat | Implement a standalone flag for new applications | +| [5ceedcb11](https://github.com/angular/angular-cli/commit/5ceedcb11e3ca5bdad4248c7c76ca2562fab43f2) | feat | remove deprecated CanLoad option for guards | +| [c9e84d024](https://github.com/angular/angular-cli/commit/c9e84d0243b4e9191f6cfcd72ebf8288de2b6f2d) | feat | remove generation of `BrowserModule.withServerTransition` | +| [50b9e59a5](https://github.com/angular/angular-cli/commit/50b9e59a50b737e34ee12ee48ab83d17c2b8744a) | feat | update app-shell schematic to support standalone applications | +| [dc5cc893d](https://github.com/angular/angular-cli/commit/dc5cc893d6c3d4e5e6f6c4b19bee632b66a94fc0) | feat | Update universal schematic to support standalone applications | +| [f98c9de80](https://github.com/angular/angular-cli/commit/f98c9de80952593e0294538d96bdac7136629f77) | fix | add experimental message when using standalone application schematic. | +| [a5cb46124](https://github.com/angular/angular-cli/commit/a5cb46124234ec2c47f6288914ad3ed9564f3a72) | fix | add standalone option to library library | +| [b2ed7bd10](https://github.com/angular/angular-cli/commit/b2ed7bd100bfe77dca81c590b827870fd496075f) | fix | provide migration that disables build optimizer on dev server builds | +| [ba4414b2c](https://github.com/angular/angular-cli/commit/ba4414b2cfb7a040393f314d87ab823bcad75f26) | fix | reformat app.config.ts | +| [202e9a50f](https://github.com/angular/angular-cli/commit/202e9a50f62b7927c0900469b21d323b3010762d) | fix | remove compileComponents from component test schematic | +| [0d58f73c5](https://github.com/angular/angular-cli/commit/0d58f73c50ce496dd3a0166533069f450f83a461) | fix | rename `app.server.module.ts` to `app.module.server.ts` | +| [de6d30102](https://github.com/angular/angular-cli/commit/de6d30102978eebda7edbdda43ca50f18c4c8aaf) | fix | replace `provideServerSupport` with `provideServerRendering` | +| [bff634fe0](https://github.com/angular/angular-cli/commit/bff634fe0938ecb4a316064ba3f1b9c2c1f208fe) | fix | update private Components utilities to work with standalone project structure | +| [85fe820b0](https://github.com/angular/angular-cli/commit/85fe820b081b73b229084882e98e65b5c57f9d0f) | fix | use same property order in standalone AppComponent | + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------ | +| [68024234e](https://github.com/angular/angular-cli/commit/68024234edcb942d5a177d6bd7567e77d7e40245) | feat | remove deprecated `defaultCollection` from workspace configuration | +| [d58428d3d](https://github.com/angular/angular-cli/commit/d58428d3dbdb7275e2e4f6d271fcc5fdda5c489e) | feat | remove deprecated `defaultProject` from workspace configuration | +| [7cb5689e0](https://github.com/angular/angular-cli/commit/7cb5689e02c30c0ef53adef92d0e9969e1a1536b) | feat | show optional migrations during update process | +| [c29c8e18d](https://github.com/angular/angular-cli/commit/c29c8e18d84096e2f72af12643c31bde51010548) | refactor | remove Node.js v14 support | + +### + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ----- | ---------------------------------------------------------- | +| [5a171ddff](https://github.com/angular/angular-cli/commit/5a171ddff66ff366089616736baf7545fe44f570) | build | update to TypeScript 5 and drop support for TypeScript 4.8 | + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [48871381a](https://github.com/angular/angular-cli/commit/48871381a169888f1d29275ab25915b0d815d1c1) | fix | allow registered builder teardowns to execute | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------- | +| [ff5ebf9b1](https://github.com/angular/angular-cli/commit/ff5ebf9b1244c5a01961cd3dba6bb345392aa57c) | feat | add CSP support for inline styles | +| [ee8013f66](https://github.com/angular/angular-cli/commit/ee8013f66f7587ba85ed76fb0c662168fd850c47) | feat | display build output table with esbuild | +| [0eac98f61](https://github.com/angular/angular-cli/commit/0eac98f6176bde662d7d7e9532b5a988b8e7ece2) | feat | implement progress option for esbuild builder | +| [f04859d16](https://github.com/angular/angular-cli/commit/f04859d16117a41b6e8ad698a449aca73456b9d7) | feat | initial autoprefixer support for CSS in esbuild builder | +| [8c550302c](https://github.com/angular/angular-cli/commit/8c550302cc046e649f1245007e0e26550a61f931) | feat | initial development server for esbuild-based builder | +| [52969db6b](https://github.com/angular/angular-cli/commit/52969db6bdaf42ec7d7f28274eba518ed1a794b7) | feat | initial tailwindcss support for CSS in esbuild builder | +| [ce46ecae0](https://github.com/angular/angular-cli/commit/ce46ecae011595c86fea265e121ea313bb3cb030) | feat | support module resolution with less stylesheets in esbuild builder | +| [584b51907](https://github.com/angular/angular-cli/commit/584b51907c3b3f60db5478994fff3f800b70c3f2) | feat | support scripts option with esbuild builder | +| [e4883b0ee](https://github.com/angular/angular-cli/commit/e4883b0ee1d1ee7cd57e6cb374944021a100fd3b) | feat | support SSL options with esbuild development server | +| [290802060](https://github.com/angular/angular-cli/commit/2908020601e627b7c76c6fe8d53e19e8858cd325) | feat | support standalone app-shell generation | +| [766c14698](https://github.com/angular/angular-cli/commit/766c14698473fe333168c06e3b88c7303e868acf) | fix | add sourcemap `x_google_ignoreList` support for esbuild builder | +| [cdfa7ca88](https://github.com/angular/angular-cli/commit/cdfa7ca88c2e79564192d4b7fdafb53d97f2607d) | fix | allow multiple polyfills with esbuild-based builder | +| [e690b7cbd](https://github.com/angular/angular-cli/commit/e690b7cbde470b69b3c23fa9af1ecfca4c8e3a7e) | fix | always enable `looseEnums` build optimizer rule | +| [135ab4c36](https://github.com/angular/angular-cli/commit/135ab4c363d5d247342c4bc123a17eb66de17752) | fix | avoid double sourcemap comments with esbuild dev-server | +| [dcf60d2be](https://github.com/angular/angular-cli/commit/dcf60d2be26fdbc1efaec1c506188cb166ffbdf0) | fix | correctly filter lazy global styles in esbuild builder | +| [342a4ea30](https://github.com/angular/angular-cli/commit/342a4ea30e1ab9cbdbe5d6de339c21bdcff1a2c1) | fix | correctly show initial files in stat table with esbuild builder | +| [107851ae4](https://github.com/angular/angular-cli/commit/107851ae45d8399782cbc73d3fa09b3f779e1e02) | fix | display warning when `preserveWhitespaces` is set in the tsconfig provided to the server builder | +| [ff8a89cbf](https://github.com/angular/angular-cli/commit/ff8a89cbfd308a0312d16956d55c30e2425e2d33) | fix | ensure all build resources are served in esbuild dev server | +| [f76a8358e](https://github.com/angular/angular-cli/commit/f76a8358ea07a0d00fb0eb1c62dfaccf056531be) | fix | ensure directories are properly ignored in esbuild builder | +| [005ba4276](https://github.com/angular/angular-cli/commit/005ba427661f0e5907020aea10c432a324b528a8) | fix | ensure empty component styles compile with esbuild | +| [f74151baa](https://github.com/angular/angular-cli/commit/f74151baab740df15a5cc80255d97d0320147b2a) | fix | exclude `@angular/platform-server/init` from unsafe optimizations | +| [f72155bc7](https://github.com/angular/angular-cli/commit/f72155bc7025f4e0b23eb58a92e422bd341720f6) | fix | fully remove third-party sourcemaps when disabled in esbuild builder | +| [26dced95c](https://github.com/angular/angular-cli/commit/26dced95c5612f6386b3179fce50904f178ee569) | fix | JIT support for standalone applications | +| [4822b3ba5](https://github.com/angular/angular-cli/commit/4822b3ba55ec824913e895e76cf83e2b36ec99f9) | fix | keep esbuild server active until builder fully stops | +| [adbf2c8a1](https://github.com/angular/angular-cli/commit/adbf2c8a1ed67f505ea27921c00f957509e9a958) | fix | normalize long-form asset option output to relative path | +| [67670b612](https://github.com/angular/angular-cli/commit/67670b612e2397e26a974cd337cdce1a9c6a0f21) | fix | pass listening port in result for esbuild dev server | +| [1a8833b21](https://github.com/angular/angular-cli/commit/1a8833b211cbf2535d3deed1029591050bc995b8) | fix | provide option to run build-optimizer on server bundles | +| [b8c9667f9](https://github.com/angular/angular-cli/commit/b8c9667f9292d3829bfcac10a98acd859301c3c7) | fix | remove unintended files in esbuild output stats table | +| [04274afc1](https://github.com/angular/angular-cli/commit/04274afc15084ead2916e11055aa8f1d2f61951d) | fix | set public class fields as properties ([#24849](https://github.com/angular/angular-cli/pull/24849)) | +| [a778fe6c2](https://github.com/angular/angular-cli/commit/a778fe6c2e7b9ca0c0995e1350460e97085b39a1) | fix | show lazy files in stat table correctly with esbuild | +| [955b493b1](https://github.com/angular/angular-cli/commit/955b493b13e0a8956706c486d31d9e4338bf41c5) | fix | support CSP on critical CSS link tags. | +| [c272172c8](https://github.com/angular/angular-cli/commit/c272172c84bef35f63038f1fc5fa184b1e2d99bf) | fix | update esbuild builder complete log | +| [0b450578a](https://github.com/angular/angular-cli/commit/0b450578a74e2b46488ae2e97c7f76389baa5271) | fix | update list of known tailwind configuration files | +| [759ae92aa](https://github.com/angular/angular-cli/commit/759ae92aaa595fe3f6000f3aae0e6bb8d025db3a) | fix | update peer dependencies to support version 16 | +| [eca366a84](https://github.com/angular/angular-cli/commit/eca366a843be1fcc8d949bc335cac4cdcbdea41c) | fix | use preserveSymlinks option for tsconfigs in esbuild builder | +| [28c27567c](https://github.com/angular/angular-cli/commit/28c27567cf90712e6c8f4d483bcc0e0fc683ee9b) | perf | asynchronously delete output path in esbuild builder | +| [458400b7b](https://github.com/angular/angular-cli/commit/458400b7b1a435e2febe2c4e1a9fd1ca4eda58d0) | perf | avoid unnessary iterations | +| [a710a262a](https://github.com/angular/angular-cli/commit/a710a262aed8a6c4a6af48e0ad7f479f0a23212e) | perf | cache Sass in memory with esbuild watch mode | +| [e1398d333](https://github.com/angular/angular-cli/commit/e1398d333e86b6caad8b5cfef7048fefd77a9e22) | perf | do not inline sourcemap when using vite dev-server | +| [b2ece91b7](https://github.com/angular/angular-cli/commit/b2ece91b7488a01b6ddfcba1e68f97416c8b05f7) | perf | enhance Sass package resolution in esbuild builder | +| [aae34fc02](https://github.com/angular/angular-cli/commit/aae34fc02dc774d59ecac6483288f47074ee8c2d) | perf | fully lazy load sass in esbuild builder | +| [9ea3e8e34](https://github.com/angular/angular-cli/commit/9ea3e8e349dd1765d5935517999a1879a7a0227d) | perf | only import esbuild watcher when in watch mode | +| [f88ac6fdf](https://github.com/angular/angular-cli/commit/f88ac6fdfee6abf406720c9bc72aa9ddadb112f9) | perf | skip Angular linker in JIT mode with esbuild | +| [a99018cd7](https://github.com/angular/angular-cli/commit/a99018cd7bb66ee53026e06deae6a14455023910) | refactor | remove deprecated `outputPaths` and `outputPath` Builder output | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------- | +| [f6624b974](https://github.com/angular/angular-cli/commit/f6624b974faf13fa718d304e1a473260c16f0c1d) | feat | update SchemaRegistry `compile` to return `Promise` | +| [0ad81cdbc](https://github.com/angular/angular-cli/commit/0ad81cdbc72e80ca75d9d5cc2bc0c6163267a0bb) | refactor | remove deprecated exceptions | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------ | +| [d2ef386f4](https://github.com/angular/angular-cli/commit/d2ef386f46131af904ca800cc77388c03239cd9d) | refactor | remove `UpdateBuffer` and rename `UpdateBuffer2` to `UpdateBuffer` | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ----------------------- | +| [c8ac660d8](https://github.com/angular/angular-cli/commit/c8ac660d8b13922be7ebcc92dfd5b18392602c40) | refactor | remove NGCC integration | + + + + + +# 15.2.8 (2023-05-03) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [069dcdf0c](https://github.com/angular/angular-cli/commit/069dcdf0c4e614fea83af61d4496bdd8a96920ca) | docs | improve wording in doc command version description | + + + + + +# 15.2.7 (2023-04-26) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [f4a6dac87](https://github.com/angular/angular-cli/commit/f4a6dac8782808e564678b4484f3ce87e59f6c8f) | fix | process keeps running when analytics are enabled | +| [f9b2fb1c4](https://github.com/angular/angular-cli/commit/f9b2fb1c4981ff138992a502d3aba4f6a3886df4) | perf | register CLI commands lazily | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [d9aefd6da](https://github.com/angular/angular-cli/commit/d9aefd6da5bd6ea244da3a8d5ea3dcbbadd31f99) | fix | replace vscode launch type from `pwa-chrome` to `chrome` | + + + + + +# 15.2.6 (2023-04-12) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [f0b257ef4](https://github.com/angular/angular-cli/commit/f0b257ef4ae62f92d70bfd2a4e9912d4ceff9c78) | fix | ignore hidden directories when running browserlist migration | + + + + + +# 15.2.5 (2023-04-05) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------ | +| [db173d7ed](https://github.com/angular/angular-cli/commit/db173d7edf685df67b782d81d1bacb84b8debf9a) | fix | collect tech information | + +## Special Thanks + +Alan Agius + + + + + +# 15.2.4 (2023-03-16) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------- | +| [f74bfea24](https://github.com/angular/angular-cli/commit/f74bfea241b189f261ec81a8561aea7a56774ae8) | fix | update `webpack` dependency to `5.76.1` | + +## Special Thanks + +Alan Agius + + + + + +# 14.2.11 (2023-03-16) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [ddd33bf38](https://github.com/angular/angular-cli/commit/ddd33bf38d7d76e816ebc0459559917da514477d) | fix | update webpack dependency to `5.76.1` | + +## Special Thanks + +Alan Agius and Joey Perrott + + + + + +# 13.3.11 (2023-03-16) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------- | +| [50fa9300f](https://github.com/angular/angular-cli/commit/50fa9300f264f68ad35606ae46da820c3798f665) | fix | update `webpack` dependency to `5.76.1` | + +## Special Thanks + +Alan Agius and Joey Perrott + + + + + +# 15.2.3 (2023-03-15) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [a93680585](https://github.com/angular/angular-cli/commit/a9368058517509b277236d6e7db4abc6248817fa) | fix | correct wrap ES2022 classes with static properties | + +## Special Thanks + +Alan Agius and Paul Gschwendtner + + + + + +# 15.2.2 (2023-03-08) + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [dfd03aa7c](https://github.com/angular/angular-cli/commit/dfd03aa7c262f4425fa680e205a46792bd7b8451) | fix | correctly transform numbers from prompts | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [eb22f634f](https://github.com/angular/angular-cli/commit/eb22f634f2ec7a5b0bc2f5300682ed8e718b1424) | fix | build optimizer support for non spec-compliant ES2022 class static properties | + +## Special Thanks + +Alan Agius + + + + + +# 15.2.1 (2023-03-01) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------- | +| [9a5609a44](https://github.com/angular/angular-cli/commit/9a5609a440fc49b3f7ddf88efb73618b7eede1ea) | fix | improve parsing of error messages | + +## Special Thanks + +Alan Agius and Paul Gschwendtner + + + + + +# 15.2.0 (2023-02-22) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [0f58a17c4](https://github.com/angular/angular-cli/commit/0f58a17c4ce92495d96721bc3f2b632a890bbab4) | feat | log number of files update during `ng update` | + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------- | +| [ecf43090d](https://github.com/angular/angular-cli/commit/ecf43090d110f996f45a259c279f1b83dcab3fd8) | feat | auto detect package manager ([#24305](https://github.com/angular/angular-cli/pull/24305)) | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [01b3bcf89](https://github.com/angular/angular-cli/commit/01b3bcf898108f9b879da4a791fa2a21c6d9f7c5) | feat | add Less stylesheet support to experimental esbuild-based builder | +| [09af70743](https://github.com/angular/angular-cli/commit/09af70743800aefdefe06e0ca32bcdde18f9eb77) | feat | implement node module license extraction for esbuild builder | +| [bbc1a4f0d](https://github.com/angular/angular-cli/commit/bbc1a4f0dc93437fe97a53a35f68d978cc50bb9e) | feat | support CommonJS dependency checking in esbuild | +| [8cf0d17fb](https://github.com/angular/angular-cli/commit/8cf0d17fb1b39ea7bbd1c751995a56de3df45114) | feat | support JIT compilation with esbuild | +| [3f6769ef9](https://github.com/angular/angular-cli/commit/3f6769ef953b1f880508a9152e669064cbb4dcc9) | fix | allow empty scripts to be optimized | +| [421417a36](https://github.com/angular/angular-cli/commit/421417a36b13a44d39e0818171482871ea8b895f) | fix | avoid CommonJS warning for zone.js in esbuild | + +## Special Thanks + +Alan Agius, Charles Lyding, Doug Parker, Jason Bedard, Joey Perrott, Marvin and Paul Gschwendtner + + + + + +# 15.1.6 (2023-02-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------- | +| [3d1f48fa2](https://github.com/angular/angular-cli/commit/3d1f48fa2991ded75da3a1b3a431480710a8ce15) | fix | add set `SessionEngaged` in GA | +| [df07ab113](https://github.com/angular/angular-cli/commit/df07ab11351d6f2d82922ae251ccd17b23d9d0a9) | fix | convert `before` option in `.npmrc` to Date | +| [c787cc780](https://github.com/angular/angular-cli/commit/c787cc7803598eb67260cbd2112d411384d518cc) | fix | replace `os.version` with `os.release`. | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [34a4a1bbf](https://github.com/angular/angular-cli/commit/34a4a1bbf608eb54b0a33b3aa3a6be3e2a576770) | fix | correctly copy `safety-worker.js` contents | +| [88a33155d](https://github.com/angular/angular-cli/commit/88a33155d4bc00077d32bef42588427fb2ed49f4) | fix | update the ECMA output warning message to be more actionable | +| [384ad29c9](https://github.com/angular/angular-cli/commit/384ad29c9a66d78e545ed7e48bf962e4df9d0549) | fix | use babel default export helper in build optimizer | +| [59aa1cdbd](https://github.com/angular/angular-cli/commit/59aa1cdbdf3e2712f988790f68bacc174d070b0c) | perf | reduce rebuilt times when using the `scripts` option | + +## Special Thanks + +Alan Agius and Charles Lyding + + + + + +# 15.1.5 (2023-02-08) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [b8bbe9688](https://github.com/angular/angular-cli/commit/b8bbe9688e0e684245636e7d58d50c51719039c8) | fix | error if Angular compiler is used in a schematic | +| [fabbb8a93](https://github.com/angular/angular-cli/commit/fabbb8a936f3b3b1cee8ea5cbdb7bb7832cb02a7) | fix | only set `DebugView` when `NG_DEBUG` is passed | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [499173b5d](https://github.com/angular/angular-cli/commit/499173b5d197f14377203b92b49ff3cbbf55b260) | fix | remove bootstrapping wrapping in universal schematic | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [e87134fe9](https://github.com/angular/angular-cli/commit/e87134fe94831df76698fe0e90fe556da0011511) | fix | build optimizer support for spec-compliant downlevel class properties | +| [d80adde2f](https://github.com/angular/angular-cli/commit/d80adde2fec53e6513983a89dd194a35c426b8aa) | fix | do not fail compilation when spec pattern does not match | +| [11be502e7](https://github.com/angular/angular-cli/commit/11be502e7cc2544371d55c8b3d32b7bcbbf8066e) | fix | fix support of Safari TP versions | +| [14e317d85](https://github.com/angular/angular-cli/commit/14e317d85429c83e6285c5cec4a1c4483d8a1c8f) | fix | load polyfills and runtime as scripts instead of modules | + +## Special Thanks + +Alan Agius, Charles Lyding, Kristiyan Kostadinov and Ricardo + + + + + +# 15.1.4 (2023-02-01) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------- | +| [6c8fdfc69](https://github.com/angular/angular-cli/commit/6c8fdfc6985c5b5017a0b6ab6fa38daf4cb9a775) | fix | load JavaScript bundles as modules in karma | +| [317452e3b](https://github.com/angular/angular-cli/commit/317452e3b7e25080132b7f7a069696d1c5054f69) | fix | print server builder errors and warnings | + +## Special Thanks + +Alan Agius + + + + + +# 15.1.3 (2023-01-25) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [de15ec576](https://github.com/angular/angular-cli/commit/de15ec5763afe231439c3f1ace35cbacefad2ca7) | fix | handle extended schematics when retrieving aliases | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [2c04f4a8f](https://github.com/angular/angular-cli/commit/2c04f4a8f493781fda65f31e81ad86cdd3e510c0) | fix | update browserslist config to include last 2 Chrome version | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [f31bf300b](https://github.com/angular/angular-cli/commit/f31bf300b9f226d9574060b0e4401c4da88c0ee3) | fix | avoid undefined module path for Sass imports in esbuild | +| [c152a4a13](https://github.com/angular/angular-cli/commit/c152a4a13f482948c6aedbbc99d1423f2cf43aea) | fix | update browserslist config to include last 2 Chrome versions | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [9de99202e](https://github.com/angular/angular-cli/commit/9de99202e9427973c7983940fcdea9e4580a79bd) | fix | handle number like strings in workspace writer | + +## Special Thanks + +Alan Agius, Charles Lyding and Doug Parker + + + + + +# 15.1.2 (2023-01-18) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------- | +| [387472a95](https://github.com/angular/angular-cli/commit/387472a956b71eaca89e210e64f4d75969abc9d3) | fix | register schematic aliases when providing collection name in `ng generate` | +| [5d9fd788a](https://github.com/angular/angular-cli/commit/5d9fd788a997066dea1b2d69dced865a7c60f5c1) | fix | remove `--to` option from being required when using `--from` in `ng update` | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------- | +| [0f5fb7e59](https://github.com/angular/angular-cli/commit/0f5fb7e5944e3a521758c67f403d71928f93f7ac) | fix | replace existing `BrowserModule.withServerTransition` calls when running universal schematic | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [bf4639a6e](https://github.com/angular/angular-cli/commit/bf4639a6e97670972c3d5b137230e2f08467010e) | fix | prevent hanging initial build during exception with esbuild | + +## Special Thanks + +Alan Agius, Charles Lyding and Doug Parker + + + + + +# 15.1.1 (2023-01-12) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------- | +| [b94bf60ca](https://github.com/angular/angular-cli/commit/b94bf60ca828a22d548d65b819ea745eafb96deb) | fix | update `esbuild` to `0.16.17` | + +## Special Thanks + +Alan Agius + + + + + +# 15.1.0 (2023-01-11) + +## Deprecations + +### @angular-devkit/schematics + +- The Observable based `SchematicTestRunner.runSchematicAsync` and `SchematicTestRunner.runExternalSchematicAsync` method have been deprecated in favor of the Promise based `SchematicTestRunner.runSchematic` and `SchematicTestRunner.runExternalSchematic`. + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------- | +| [5b18ce154](https://github.com/angular/angular-cli/commit/5b18ce1545d047d49851a64e81a1f8ef59624ef7) | feat | add `guardType` as an alias of `implements` in guard schematic | +| [dd2b65943](https://github.com/angular/angular-cli/commit/dd2b65943d706833f449f76cf8c7278d0a5399ad) | feat | add configuration files generation schematic | +| [8d000d156](https://github.com/angular/angular-cli/commit/8d000d1563684f9a9b6869e549e265f0997187c4) | feat | add environments generation schematic | +| [6c39a162b](https://github.com/angular/angular-cli/commit/6c39a162bec67083bf6c11b54e84612f1d68c384) | feat | Add schematics for generating functional router guards and resolvers | +| [62121f89a](https://github.com/angular/angular-cli/commit/62121f89abce54e0a1c2b816cdd32b57f2b5a5d1) | feat | add sideEffects:false to library package.json | +| [9299dea64](https://github.com/angular/angular-cli/commit/9299dea6492527bcaea24c9c7f3116ee2779405b) | feat | generate functional interceptors | +| [49b313f27](https://github.com/angular/angular-cli/commit/49b313f27adef6300063c9d6817d1454a8657fe2) | fix | add missing import for functional interceptor spec | +| [2f92fe7e5](https://github.com/angular/angular-cli/commit/2f92fe7e589705b282102271897454ea852c4814) | fix | add missing semicolon in functional guard/resolver/interceptor | +| [9b6d190f4](https://github.com/angular/angular-cli/commit/9b6d190f4a082c166d253b0f00162e0286238e45) | fix | remove EnvironmentInjector import in functional guard spec | +| [b11d3f644](https://github.com/angular/angular-cli/commit/b11d3f6442d38f609471ab19c08a1c9a871e0ae3) | fix | use proper variable in functional guard spec | +| [451975f76](https://github.com/angular/angular-cli/commit/451975f7650041a83994e1308f85fe7e33a31e32) | fix | use proper variable in resolver functional spec | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [c29df6954](https://github.com/angular/angular-cli/commit/c29df695467c41feccd3846a55c91c6784af87b2) | feat | add `assets` option to server builder | +| [839d0cb57](https://github.com/angular/angular-cli/commit/839d0cb57ad42896578c235354ffb918ea8bb146) | feat | implement stats-json option for esbuild builder | +| [216991b9d](https://github.com/angular/angular-cli/commit/216991b9d9ca1d8f09992880a5fa92e7c98813fa) | feat | support inline component Sass styles with esbuild builder | +| [7c87ce47c](https://github.com/angular/angular-cli/commit/7c87ce47c66a6426b6b7fbb2edd38d8da729221f) | fix | ensure Sass load paths are resolved from workspace root | +| [7a063238b](https://github.com/angular/angular-cli/commit/7a063238b83eea8b5b3237fed12db5528d1f6912) | fix | explicitly send options to JS transformer workers | +| [22cba7937](https://github.com/angular/angular-cli/commit/22cba79370ed60a27f932acda363ffd87f5d9983) | fix | provide an option to `exclude` specs in Karma builder | +| [20376649c](https://github.com/angular/angular-cli/commit/20376649c5e3003b0aa99b9328e2b61699ccba78) | fix | transform async generator class methods for Zone.js support | +| [0520608f6](https://github.com/angular/angular-cli/commit/0520608f68f1768a13a46fbdb9ecb65310492460) | fix | use relative css resource paths in esbuild JSON stats | +| [0c01532cb](https://github.com/angular/angular-cli/commit/0c01532cb5a3072b96cd65845a38b88ed4543de6) | perf | use worker pool for JavaScript transforms in esbuild builder | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [207358afb](https://github.com/angular/angular-cli/commit/207358afb89e6515cb8d73f5a3a63d9101e80d97) | feat | add `runSchematic` and `runExternalSchematic` methods | + +## Special Thanks + +Alan Agius, Andrew Scott, Charles Lyding, Cédric Exbrayat, Doug Parker, Felix Hamann, Jason Bedard, Joey Perrott and Kristiyan Kostadinov + + + + + +# 15.0.5 (2023-01-06) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [c2030dec7](https://github.com/angular/angular-cli/commit/c2030dec7d9fecf42cca2de37cc3f7adaaa45e7f) | fix | format esbuild error messages to include more information | + +## Special Thanks + +Alan Agius, Kristiyan Kostadinov, Paul Gschwendtner and aanchal + + + + + +# 15.0.4 (2022-12-14) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [ccc8e0350](https://github.com/angular/angular-cli/commit/ccc8e0350810d123269f55de29acd7964e663f7e) | fix | display actionable error when a style does not exist in Karma builder | +| [507f756c3](https://github.com/angular/angular-cli/commit/507f756c34171db842365398150460e1e29f531a) | fix | downlevel class private methods when targeting Safari <=v15 | +| [a0da91dba](https://github.com/angular/angular-cli/commit/a0da91dba3d9b4c4a86102668f52ab933406e5da) | fix | include sources in generated | +| [9fd356234](https://github.com/angular/angular-cli/commit/9fd356234210734ec5f44ae18f055308b7acc963) | fix | only set ngDevMode when script optimizations are enabled | +| [8e85f4728](https://github.com/angular/angular-cli/commit/8e85f47284472f9df49f2ca6c59057ad28240e9c) | fix | update `css-loader` to `6.7.3` | +| [b2d4415ca](https://github.com/angular/angular-cli/commit/b2d4415caa486bebe55e6147a153f120cf08b070) | fix | update locale setting snippet to use `globalThis`. | + +## Special Thanks + +Alan Agius and Charles Lyding + + + + + +# 15.0.3 (2022-12-07) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [3d9971edb](https://github.com/angular/angular-cli/commit/3d9971edb05e9b8de24bafc1b4381cbf4bad8dbf) | fix | default preserve symlinks to Node.js value for esbuild | +| [24f4b51d2](https://github.com/angular/angular-cli/commit/24f4b51d22a0debc8ff853cf9040a15273654f7a) | fix | downlevel class fields with Safari <= v15 for esbuild | +| [45afc42db](https://github.com/angular/angular-cli/commit/45afc42db86e58357d1618d9984dcf03bffea957) | fix | downlevel class properties when targeting Safari <=v15 | +| [e6461badf](https://github.com/angular/angular-cli/commit/e6461badf7959ff8b8d9a3824a4a081f44e0b237) | fix | prevent optimization adding unsupported ECMASCript features | + +## Special Thanks + +Charles Lyding, Dominic Elm and Paul Gschwendtner + + + + + +# 15.0.2 (2022-11-30) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [2891d5bc9](https://github.com/angular/angular-cli/commit/2891d5bc9eecf7fa8e3b80906d9c56e6a49f3d15) | fix | correctly set Sass quietDeps and verbose options | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [d9cc4b028](https://github.com/angular/angular-cli/commit/d9cc4b0289eaf382782a994a15497e9526c5a4a2) | fix | elide unused type references | + +## Special Thanks + +Alan Agius and Juuso Valkeejärvi + + + + + +# 15.0.1 (2022-11-23) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [eda96def4](https://github.com/angular/angular-cli/commit/eda96def48e11533cd0a3353c96b7eac9a881e1e) | fix | use global version of the CLI when running `ng new` | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [48426852b](https://github.com/angular/angular-cli/commit/48426852b0c1d5541a3e7369dc2b343e33856968) | fix | show warning when a TS Config is not found during migrations | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [2af32fd3a](https://github.com/angular/angular-cli/commit/2af32fd3a981b1c29e1cf77b442982e1e07aae38) | fix | hide loader paths in webpack warnings | +| [19f5cc746](https://github.com/angular/angular-cli/commit/19f5cc746ec724f15d1b89126c7c1b8a343818fe) | fix | improve package deep import Sass index resolution in esbuild plugin | +| [2220a907d](https://github.com/angular/angular-cli/commit/2220a907daf9ccd9e22dfc8e5ddc259b9d495997) | fix | use url function lexer to rebase Sass URLs | + +## Special Thanks + +Alan Agius, Charles Lyding, Doug Parker, Joey Perrott and Piotr Wysocki + + + + + +# 15.0.0 (2022-11-16) + +## Breaking Changes + +### @angular/cli + +- The Angular CLI no longer supports `16.10.x`, `16.11.x` and `16.12.x`. Current minimum versions of Node.js are `14.20.0`, `16.13.0` and `18.10.0`. +- Node.js versions older than 14.20 are no longer supported. +- The 'path' option in schematics schema no longer has a special meaning. Use 'workingDirectory' smart default provider should be used instead. + +### @schematics/angular + +- Removed unused `appDir` option from Universal and App-Shell schematic. This option can safely be removed if present since it no longer has effect. + +### + +- `analyticsSharing` option in the global angular configuration has been + removed without replacement. This option was used to configure the Angular CLI to access to your own users' CLI usage data. + + If this option is used, it can be removed using `ng config --global cli.analyticsSharing undefined`. + +- analytics APIs have been removed without replacement from `@angular-devkit/core` and `@angular-devkit/architect`. + +### @angular-devkit/build-angular + +- TypeScript versions older than 4.8.2 are no longer supported. +- The server builder `bundleDependencies` option has been removed. This option was used pre Ivy. Currently, using this option is unlikely to produce working server bundles. + + The `externalDependencies` option can be used instead to exclude specific node_module packages from the final bundle. + +- - Deprecated support for tilde import has been removed. Please update the imports by removing the `~`. + + Before + + ```scss + @import '~font-awesome/scss/font-awesome'; + ``` + + After + + ```scss + @import 'font-awesome/scss/font-awesome'; + ``` + + - By default the CLI will use Sass modern API, While not recommended, users can still opt to use legacy API by setting `NG_BUILD_LEGACY_SASS=1`. + +- Internally the Angular CLI now always set the TypeScript `target` to `ES2022` and `useDefineForClassFields` to `false` unless the target is set to `ES2022` or later in the TypeScript configuration. To control ECMA version and features use the Browerslist configuration. +- `require.context` are no longer parsed. Webpack specific features are not supported nor guaranteed to work in the future. +- Producing ES5 output is no longer possible. This was needed for Internet Explorer which is no longer supported. All browsers that Angular supports work with ES2015+ +- server builder `bundleDependencies` option now only accept a boolean value. +- Deprecated support for Stylus has been removed. The Stylus package has never reached a stable version and its usage in the Angular CLI is minimal. It's recommended to migrate to another CSS preprocessor that the Angular CLI supports. + +### @angular-devkit/core + +- Workspace projects with missing `root` is now an error. + +### @ngtools/webpack + +- TypeScript versions older than 4.8.2 are no longer supported. + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------ | +| [766d4a089](https://github.com/angular/angular-cli/commit/766d4a0895e7895211e93bc73ff131c6e47613a7) | feat | add migration to remove require calls from karma builder main file | +| [d8bff4f1e](https://github.com/angular/angular-cli/commit/d8bff4f1e68a76da1983f9d0774f415e73dfd8c3) | feat | Added --project-root option to the library schematics | +| [597bfea1b](https://github.com/angular/angular-cli/commit/597bfea1b29cc7b25d1f466eb313cbeeb6dffc98) | feat | drop `polyfills.ts` file from new templates | +| [1c21e470c](https://github.com/angular/angular-cli/commit/1c21e470c76d69d08e5096b46b952dbce330f7ef) | feat | enable error on unknown properties and elements in tests | +| [f2a0682dc](https://github.com/angular/angular-cli/commit/f2a0682dc82afa23a3d3481df59e4aaca5e90c78) | feat | generate new projects using TypeScript 4.8.2 | +| [b06421d15](https://github.com/angular/angular-cli/commit/b06421d15e4b5e6daffcb73ee1c2c8703b72cb47) | feat | mark `projectRoot` as non hidden option in application schematic | +| [b6897dbb0](https://github.com/angular/angular-cli/commit/b6897dbb0a1ef287644e117251c1c76cc8afcae0) | feat | remove `karma.conf.js` from newly generated projects | +| [301b5669a](https://github.com/angular/angular-cli/commit/301b5669a724261d53444d5172334966903078c0) | feat | remove `ngOnInit` from component template | +| [9beb878e2](https://github.com/angular/angular-cli/commit/9beb878e2eecd32e499c8af557f22f46548248fc) | feat | remove Browserslist configuration files from projects | +| [283b564d1](https://github.com/angular/angular-cli/commit/283b564d1de985f0af8c2fcb6192801a90baacda) | feat | remove environment files in new applications | +| [56a1e8f9f](https://github.com/angular/angular-cli/commit/56a1e8f9f52658488afb9d36007e96c96d08a03b) | feat | remove test.ts file from new projects | +| [4e69e8050](https://github.com/angular/angular-cli/commit/4e69e80501dd2a9394b7df4518e0d6b0f2ebb7d9) | fix | add `@angular/localize` as type when localize package is installed | +| [57d93fb7d](https://github.com/angular/angular-cli/commit/57d93fb7d979e68c2a4e6f6046ff633f69098afe) | fix | mark project as required option | +| [84e3f7727](https://github.com/angular/angular-cli/commit/84e3f7727dc1de31484704c7c06d51ff5392a34a) | fix | remove empty lines | +| [316a50d75](https://github.com/angular/angular-cli/commit/316a50d75e45962ea3efe4108aa48d9479245dd5) | fix | remove TypeScript target from universal schematic | +| [69b221498](https://github.com/angular/angular-cli/commit/69b2214987c8fad6efd091782cf28b20be62d244) | refactor | remove deprecated appDir option | + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- | +| [4827d1b23](https://github.com/angular/angular-cli/commit/4827d1b23e564e4e4a8684c5e8ff035d8fa855a2) | feat | add support for Node.js version 18 | +| [4b623461a](https://github.com/angular/angular-cli/commit/4b623461a4a938ba320b5e019f9c715d634a46c4) | feat | drop support for Node.js versions older than 14.20 | +| [3dea1fa71](https://github.com/angular/angular-cli/commit/3dea1fa7173e846aff5b0d15b919d9786bbf7198) | fix | add unique user id as user parameter in GA | +| [af07aa340](https://github.com/angular/angular-cli/commit/af07aa340a1c3c9f3d42446981be59a73effa498) | fix | add workspace information as part of analytics collection | +| [83524f625](https://github.com/angular/angular-cli/commit/83524f62533f9a6bda0c1dbc76c6b16e730a7397) | fix | allow `ng add` to find prerelease versions when CLI is prerelease | +| [22955f245](https://github.com/angular/angular-cli/commit/22955f24592df8044dbdeeb8e635beb1cc770c75) | fix | do not collect analytics when running in non TTY mode | +| [35e5f4278](https://github.com/angular/angular-cli/commit/35e5f4278145b7ef55a75f1692c8e92d6bcd59db) | fix | exclude `@angular/localize@<10.0.0` from ng add pa… ([#24152](https://github.com/angular/angular-cli/pull/24152)) | +| [1a584364e](https://github.com/angular/angular-cli/commit/1a584364e70cafd84770ef45f3da9ad58a46083f) | fix | exclude `@angular/material@7.x` from ng add package discovery | +| [ff0382718](https://github.com/angular/angular-cli/commit/ff0382718af60923fe71f8b224d36a50449484e6) | fix | respect registry in RC when running update through yarn | +| [774d349b7](https://github.com/angular/angular-cli/commit/774d349b73a436a99f2ea932b7509dab7c1d5e45) | refactor | remove deprecated path handler | + +### + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------- | +| [639a3071c](https://github.com/angular/angular-cli/commit/639a3071c3630c1ccdf7e3c015e81e9423ab2678) | refactor | migrate analytics collector to use GA4 | +| [c969152de](https://github.com/angular/angular-cli/commit/c969152de630a9afdef44ba2342e728b9353c8e7) | refactor | remove analytics API from core and architect | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------- | +| [4ead45cab](https://github.com/angular/angular-cli/commit/4ead45caba08cb0b67dc7df2f6a9b304c75fff7d) | feat | add `ng-server-context` when using app-shell builder | +| [1c527a9da](https://github.com/angular/angular-cli/commit/1c527a9da5b55a8421ebca787fd322e879f6d29d) | feat | add esbuild-based builder initial support for fileReplacements | +| [67324b3e5](https://github.com/angular/angular-cli/commit/67324b3e5861510b1df9641bb4b10bb67e3a2325) | feat | add initial incremental code rebuilding to esbuild builder | +| [3d94ca21b](https://github.com/angular/angular-cli/commit/3d94ca21bbb7496a2ff588166fd93c5f2339b823) | feat | add initial watch support to esbuild-based builder | +| [c592ec584](https://github.com/angular/angular-cli/commit/c592ec584f1c0b126a2045e5ea1b01cb1569ce4d) | feat | amend `polyfills` option in all builders to support an array of module specifiers | +| [a95d130ef](https://github.com/angular/angular-cli/commit/a95d130ef4249457ed2433d52eb43c94a1169782) | feat | auto include `@angular/localize/init` when found in `types` | +| [979bce45e](https://github.com/angular/angular-cli/commit/979bce45e63eda9ac5402869ef3dc4c63aaca3f1) | feat | auto include `@angular/platform-server/init` during server builds | +| [fd4175357](https://github.com/angular/angular-cli/commit/fd41753579affa78328bfc4b6108db15ff5053f9) | feat | drop support for TypeScript 4.6 and 4.7 | +| [15d3fc6dc](https://github.com/angular/angular-cli/commit/15d3fc6dc3f74462818b3745f6fb4995212a4d22) | feat | export `@angular/platform-server` symbols in server bundle | +| [05a98c029](https://github.com/angular/angular-cli/commit/05a98c02924f656be3257d5f459ae88c1ae29fba) | feat | karma builder `main` option is now optional | +| [2b6029245](https://github.com/angular/angular-cli/commit/2b602924538bf987e92f806c25c2a3d008a3f0a9) | feat | providing a karma config is now optional | +| [9c13fce16](https://github.com/angular/angular-cli/commit/9c13fce162eff8d01d1fa6a7f0e0029da2887c86) | feat | remove `bundleDependencies` from server builder | +| [308e3a017](https://github.com/angular/angular-cli/commit/308e3a017f876bfc727e68803bfbce11e9d3396e) | feat | switch to use Sass modern API | +| [1e5d4a750](https://github.com/angular/angular-cli/commit/1e5d4a75084dfd2aeebb6a0c0b3039417e14bc84) | feat | use Browserslist to determine ECMA output | +| [3ff391738](https://github.com/angular/angular-cli/commit/3ff39173808f2beed97ee5deb91be541205f9a03) | fix | account for package.json exports fields with CSS import statements | +| [001445982](https://github.com/angular/angular-cli/commit/0014459820dc1c127e93993414c154947a7f8da6) | fix | account for package.json exports with Sass in esbuild builder | +| [6280741ce](https://github.com/angular/angular-cli/commit/6280741ce4a89882595c834f48a45cca6f9534e0) | fix | add `@angular/platform-server` as an optional peer dependency | +| [f9a2c3a12](https://github.com/angular/angular-cli/commit/f9a2c3a1216cf9510e122df44a64ddd11d47226b) | fix | allow both script and module sourceTypes to be localized | +| [4cb27b803](https://github.com/angular/angular-cli/commit/4cb27b8031d0f36e687c5116538ebe473acaa149) | fix | avoid attempted resolve of external CSS URLs with esbuild builder | +| [192e0e6d7](https://github.com/angular/angular-cli/commit/192e0e6d77d4f0f20af3f88b653c5196a2c1e052) | fix | correct escaping of target warning text in esbuild builder | +| [4fcb0a82b](https://github.com/angular/angular-cli/commit/4fcb0a82b5fa8a092d8c374cdea448edd80270d4) | fix | correctly resolve Sass partial files in node packages | +| [fb5a66ae6](https://github.com/angular/angular-cli/commit/fb5a66ae66b595602d2a8aea8e938efe5df6d13c) | fix | fix crash when Sass error occurs | +| [b6df9c136](https://github.com/angular/angular-cli/commit/b6df9c1367ae5795a3895628ec9822d432b315bb) | fix | handle conditional exports in `scripts` and `styles` option | +| [0ee7625d6](https://github.com/angular/angular-cli/commit/0ee7625d6b4bd84be6fca0df82f3e74e4b94728c) | fix | ignore cache path when watching with esbuild builder | +| [e34bfe5eb](https://github.com/angular/angular-cli/commit/e34bfe5eb1a559cbf53449ce213503e32fa27ae4) | fix | ignore specs in node_modules when finding specs | +| [f143171fd](https://github.com/angular/angular-cli/commit/f143171fd030fa1cc8df84ed5f0b96f5ad0f9e10) | fix | only add `@angular/platform-server/init` when package is installed. | +| [3a1970b76](https://github.com/angular/angular-cli/commit/3a1970b76e4da7424e2661664a1e9e669bd279b4) | fix | only import karma when running karma builder | +| [8b84c18ed](https://github.com/angular/angular-cli/commit/8b84c18edd01e91c7ebf4327dde8ce60f7f700ca) | fix | provide workaround for V8 object spread performance defect | +| [7dd122ad5](https://github.com/angular/angular-cli/commit/7dd122ad5f34a488f3784326b579b8a93511af7e) | fix | rebase Sass url() values when using esbuild-based builder | +| [2105964af](https://github.com/angular/angular-cli/commit/2105964afc0285cc40c16d32c47d1eb60be5e279) | fix | resolve transitive dependencies in Sass when using Yarn PNP | +| [54e1c01d8](https://github.com/angular/angular-cli/commit/54e1c01d8b608ff240f7559ca176cd50e991952c) | fix | show file replacement in TS missing file error in esbuild builder | +| [6c3f281d9](https://github.com/angular/angular-cli/commit/6c3f281d927c9ae2d4ec76ff9f920752e2cb73d1) | fix | show warning when using TypeScript target older then ES2022 in esbuild builder | +| [8f8e02c32](https://github.com/angular/angular-cli/commit/8f8e02c3221c9477ec931bb6983daf6a2c8dc8be) | fix | support Yarn PNP resolution in modern SASS API | +| [fc82e3bec](https://github.com/angular/angular-cli/commit/fc82e3bec3f188d449e952d9955b845b2efdcd6b) | fix | update browerslist package | +| [0d62157a3](https://github.com/angular/angular-cli/commit/0d62157a30a246c1e00273c2300b9251574e75ae) | fix | update sourcemaps when rebasing Sass url() functions in esbuild builder | +| [1518133db](https://github.com/angular/angular-cli/commit/1518133db3b1c710500786f9f1fcfa05a016862e) | fix | use relative sourcemap source paths for Sass in esbuild builder | +| [fb4ead2ce](https://github.com/angular/angular-cli/commit/fb4ead2ce0de824eef46ce8e27a8f6cc1d08c744) | fix | wait during file watching to improve multi-save rebuilds for esbuild builder | +| [b059fc735](https://github.com/angular/angular-cli/commit/b059fc73597c12330a96fca5f6ab9b1ca226136c) | fix | warn when components styles sourcemaps are not generated when styles optimization is enabled | +| [9d0872fb5](https://github.com/angular/angular-cli/commit/9d0872fb5e369f714633387d9ae39c4242ba1ea1) | perf | add initial global styles incremental rebuilds with esbuild builder | +| [0fe6b3b75](https://github.com/angular/angular-cli/commit/0fe6b3b75b87f6f8050b196615e1c1543b707841) | perf | add vendor chunking to server builder | +| [8c915d414](https://github.com/angular/angular-cli/commit/8c915d41496c99fb42ae3992d9c91de542260bf2) | perf | avoid extra babel file reads in esbuild builder rebuilds | +| [919fe2148](https://github.com/angular/angular-cli/commit/919fe2148885c44655ce36085768b1eab2c8c246) | perf | avoid extra TypeScript emits with esbuild rebuilds | +| [92145c4a7](https://github.com/angular/angular-cli/commit/92145c4a7d2c835b703319676bafd8ea3b4a19f0) | perf | avoid template diagnostics for declaration files in esbuild builder | +| [52db3c000](https://github.com/angular/angular-cli/commit/52db3c00076dfe118cd39d7724229210c30665e0) | perf | minimize Angular diagnostics incremental analysis in esbuild-based builder | +| [feb06753d](https://github.com/angular/angular-cli/commit/feb06753d59f782c6ad8fd59a60537863094f498) | perf | use esbuild-based builder to directly downlevel for await...of | +| [9d83fb91b](https://github.com/angular/angular-cli/commit/9d83fb91b654eed79a5c9c9691d0f1c094f37771) | perf | use Sass worker pool for Sass support in esbuild builder | +| [45a94228f](https://github.com/angular/angular-cli/commit/45a94228fb23acbd0d1a9329448f07b759c8654b) | perf | use Uint8Arrays for incremental caching with esbuild-based builder | +| [f393b0928](https://github.com/angular/angular-cli/commit/f393b09282582da47db683344e037fd1434b32a8) | refactor | disable `requireContext` parsing | +| [12931ba8c](https://github.com/angular/angular-cli/commit/12931ba8c3772b1dd65846cbd6146804b08eab31) | refactor | remove deprecated ES5 support | +| [7f1017e60](https://github.com/angular/angular-cli/commit/7f1017e60f82389568065478d666ae4be6ebfea2) | refactor | remove old `bundleDependencies` enum logic | +| [2ba44a433](https://github.com/angular/angular-cli/commit/2ba44a433c827413a53d12de0ef203f8988ddc2a) | refactor | remove support for Stylus | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [ea4c0aa2e](https://github.com/angular/angular-cli/commit/ea4c0aa2e84d48be37b75e37c99ad381122297c3) | fix | throw error when project has missing root property | +| [de467f46d](https://github.com/angular/angular-cli/commit/de467f46de63059f9c701dfe8695513c742f22b5) | fix | update logger `forEach` `promiseCtor` type | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------ | +| [9b07b469b](https://github.com/angular/angular-cli/commit/9b07b469b622e083a9915ed3c24e1d53d8abf38f) | refactor | remove `UpdateBuffer` and rename `UpdateBuffer2` to `UpdateBuffer` | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------- | +| [43bd0abc1](https://github.com/angular/angular-cli/commit/43bd0abc147cf3177e707624bf6163b3dc9e06f8) | feat | drop support for TypeScript 4.6 and 4.7 | +| [1c1f985b9](https://github.com/angular/angular-cli/commit/1c1f985b9c9913f28915f101ee1717c0da540362) | fix | support inline style sourcemaps when using css-loader for component styles | + +## Special Thanks + +Alan Agius, Brent Schmidt, Charles Lyding, Cédric Exbrayat, Dariusz Ostolski, Doug Parker, Günhan Gülsoy, Jason Bedard, Lukas Spirig, Ruslan Lekhman, angular-robot[bot] and minijus + + + + + +# 14.2.10 (2022-11-17) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------- | +| [9ce386caf](https://github.com/angular/angular-cli/commit/9ce386caf6037f21f422a785fec977634406d208) | fix | exclude `@angular/localize@<10.0.0` from ng add pa… ([#24152](https://github.com/angular/angular-cli/pull/24152)) | +| [6446091a3](https://github.com/angular/angular-cli/commit/6446091a310f327ceeb68ae85f3673f6e3e83286) | fix | exclude `@angular/material@7.x` from ng add package discovery | +| [7541e04f3](https://github.com/angular/angular-cli/commit/7541e04f36ff32118e93588be38dcbb5cc2c92a9) | fix | respect registry in RC when running update through yarn | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------- | +| [21cea0b42](https://github.com/angular/angular-cli/commit/21cea0b42f08bf56990bdade82e2daa7c33011ed) | fix | update `loader-utils` to `3.2.1` | + +## Special Thanks + +Alan Agius and Charles Lyding + + + + + +# 13.3.10 (2022-11-17) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------- | +| [f298ebbd5](https://github.com/angular/angular-cli/commit/f298ebbd5f86077985d994662314379df92b6771) | fix | update `loader-utils` to `3.2.1` | + +## Special Thanks + +Alan Agius + + + + + +# 14.2.9 (2022-11-09) + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [e3e787767](https://github.com/angular/angular-cli/commit/e3e78776782da9d933f7b0e4c6bf391a62585bee) | fix | default to failure if no builder result is provided | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [12b2dc5a2](https://github.com/angular/angular-cli/commit/12b2dc5a2374f992df151af32cc80e2c2d7c4dee) | fix | isolate zone.js usage when rendering server bundles | + +## Special Thanks + +Alan Agius and Charles Lyding + + + + + +# 14.2.8 (2022-11-02) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [4b0ee8ad1](https://github.com/angular/angular-cli/commit/4b0ee8ad15efcb513ab5d9e38bf9b1e08857e798) | fix | guard schematics should include all guards (CanMatch) | + +## Special Thanks + +Andrew Scott + + + + + +# 14.2.7 (2022-10-26) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [91b5bcbb3](https://github.com/angular/angular-cli/commit/91b5bcbb31715a3c2e183e264ebd5ec1188d5437) | fix | disable version check during auto completion | +| [02a3d7b71](https://github.com/angular/angular-cli/commit/02a3d7b715f4069650389ba26a3601747e67d9c2) | fix | skip node.js compatibility checks when running completion | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------- | +| [bebed9df8](https://github.com/angular/angular-cli/commit/bebed9df834d01f72753aa0e60dc104f1781bd67) | fix | issue dev-server support warning when using esbuild builder | + +## Special Thanks + +Alan Agius and Charles Lyding + + + + + +# 14.2.6 (2022-10-12) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------ | +| [1c9cf594f](https://github.com/angular/angular-cli/commit/1c9cf594f7a855ea4b462fad53acd3bf3a2e7622) | fix | handle missing `which` binary in path | +| [28b2cd18e](https://github.com/angular/angular-cli/commit/28b2cd18e3c490cf2db64d4a6744bbd26c0aeabb) | fix | skip downloading temp CLI when running `ng update` without package names | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [ad6928184](https://github.com/angular/angular-cli/commit/ad692818413a97afe54aee6a39f0447ee9239343) | fix | project extension warning message should identify concerned project | + +## Special Thanks + +AgentEnder and Alan Agius + + + + + +# 14.2.5 (2022-10-05) + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [17eb20c77](https://github.com/angular/angular-cli/commit/17eb20c77098841d45f0444f5f047c4d44fc614f) | fix | throw more relevant error when Rule returns invalid null value | + +## Special Thanks + +Alan Agius and Charles Lyding + + + + + +# 14.2.4 (2022-09-28) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------- | +| [05b18f4e4](https://github.com/angular/angular-cli/commit/05b18f4e4b39d73c8a3532507c4b7bba8722bf80) | fix | add builders and schematic names as page titles in collected analytics | + +## Special Thanks + +Alan Agius, Jason Bedard and Paul Gschwendtner + + + + + +# 14.2.3 (2022-09-15) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [e7e0cb78f](https://github.com/angular/angular-cli/commit/e7e0cb78f4c6d684fdf25e23a11599b82807cd25) | fix | correctly display error messages that contain "at" text. | +| [4756d7e06](https://github.com/angular/angular-cli/commit/4756d7e0675aa9a8bed11b830b66288141fa6e16) | fix | watch symbolic links | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [1e3ecbdb1](https://github.com/angular/angular-cli/commit/1e3ecbdb138861eff550e05d9662a10d106c0990) | perf | avoid bootstrap conversion AST traversal where possible | + +## Special Thanks + +Alan Agius, Charles Lyding, Jason Bedard and Joey Perrott + + + + + +# 14.2.2 (2022-09-08) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------- | +| [5405a9b3b](https://github.com/angular/angular-cli/commit/5405a9b3b56675dc671e1ef27410e632f3f6f536) | fix | favor non deprecated packages during update | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [6bfd6a7fb](https://github.com/angular/angular-cli/commit/6bfd6a7fbcaf433bd2c380087803044df4c6d8ee) | fix | update minimum Angular version to 14.2 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [2b00bca61](https://github.com/angular/angular-cli/commit/2b00bca615a2c79b0a0311c83cb9f1450b6f1745) | fix | allow esbuild-based builder to use SVG Angular templates | +| [45c95e1bf](https://github.com/angular/angular-cli/commit/45c95e1bf1327532ceeb1277fa6f4ce7c3a45581) | fix | change service worker errors to compilation errors | +| [ecc014d66](https://github.com/angular/angular-cli/commit/ecc014d669efe9609177354c465f24a1c94279cd) | fix | handle service-worker serving with localize in dev-server | +| [39ea128c1](https://github.com/angular/angular-cli/commit/39ea128c1294046525a8c098ed6a776407990365) | fix | handling of `@media` queries inside css layers | +| [17b7e1bdf](https://github.com/angular/angular-cli/commit/17b7e1bdfce5823718d1fa915d25858f4b0d7110) | fix | issue warning when using deprecated tilde imports | +| [3afd784f1](https://github.com/angular/angular-cli/commit/3afd784f1f00ee07f68ba112bea7786ccb2d4f35) | fix | watch index file when running build in watch mode | + +## Special Thanks + +Alan Agius, Charles Lyding, Jason Bedard and Joey Perrott + + + + + +# 14.2.1 (2022-08-26) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [e4ca46866](https://github.com/angular/angular-cli/commit/e4ca4686627bd31604cf68bc1d2473337e26864c) | fix | update ng-packagr version to `^14.2.0` | + +## Special Thanks + +Alan Agius + + + + + +# 14.2.0 (2022-08-25) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [596037010](https://github.com/angular/angular-cli/commit/596037010a8113809657cebc9385d040922e6d86) | fix | add missing space after period in warning text | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [44c25511e](https://github.com/angular/angular-cli/commit/44c25511ea2adbd4fbe82a6122fc00af612be8e8) | feat | add ability to serve service worker when using dev-server | +| [3fb569b5c](https://github.com/angular/angular-cli/commit/3fb569b5c82f22afca4dc59313356f198755827e) | feat | switch to Sass modern API in esbuild builder | +| [5bd03353a](https://github.com/angular/angular-cli/commit/5bd03353ac6bb19c983efb7ff015e7aec3ff61d1) | fix | correct esbuild builder global stylesheet sourcemap URL | +| [c4402b1bd](https://github.com/angular/angular-cli/commit/c4402b1bd32cdb0cdd7aeab14239b57ee700d361) | fix | correctly handle parenthesis in url | +| [50c783307](https://github.com/angular/angular-cli/commit/50c783307eb1253f4f2a87502bd7a19f6a409aeb) | fix | use valid CSS comment for sourcemaps with Sass in esbuild builder | +| [4c251853f](https://github.com/angular/angular-cli/commit/4c251853fbc66c6c9aae171dc75612db31afe2fb) | perf | avoid extra string creation with no sourcemaps for esbuild sass | +| [d97640534](https://github.com/angular/angular-cli/commit/d9764053478620a5f4a3349c377c74415435bcbb) | perf | with esbuild builder only load Sass compiler when needed | + +## Special Thanks + +Alan Agius, Charles Lyding, Doug Parker, Jason Bedard, Joey Perrott, Kristiyan Kostadinov and angular-robot[bot] + + + + + +# 14.1.3 (2022-08-17) + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [365035cb3](https://github.com/angular/angular-cli/commit/365035cb37c57e07cb96e45a38f266b16b4e2fbf) | fix | update workspace extension warning to use correct phrasing | + +## Special Thanks + +AgentEnder, Alan Agius, Charles Lyding and Jason Bedard + + + + + +# 14.1.2 (2022-08-10) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [3e19c842c](https://github.com/angular/angular-cli/commit/3e19c842cc2a7f2dc62904f5f88025a4687d378a) | fix | avoid collect stats from chunks with no files | +| [d0a0c597c](https://github.com/angular/angular-cli/commit/d0a0c597cd09b1ce4d7134d3e330982b522f28a9) | fix | correctly handle data URIs with escaped quotes in stylesheets | +| [67b3a086f](https://github.com/angular/angular-cli/commit/67b3a086fe90d1b7e5443e8a9f29b12367dd07e7) | fix | process stylesheet resources from url tokens with esbuild browser builder | +| [e6c45c316](https://github.com/angular/angular-cli/commit/e6c45c316ebcd1b5a16b410a3743088e9e9f789c) | perf | reduce babel transformation in esbuild builder | +| [38b71bcc0](https://github.com/angular/angular-cli/commit/38b71bcc0ddca1a34a5a4480ecd0b170bd1e9620) | perf | use esbuild in esbuild builder to downlevel native async/await | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [dd47a5e8c](https://github.com/angular/angular-cli/commit/dd47a5e8c543cbd3bb37afe5040a72531b028347) | fix | elide type only named imports when using `emitDecoratorMetadata` | + +## Special Thanks + +Alan Agius, Charles Lyding and Jason Bedard + + + + + +# 14.1.1 (2022-08-03) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [4ee825bac](https://github.com/angular/angular-cli/commit/4ee825baca21c21db844bdf718b6ec29dc6c3d42) | fix | catch clause variable is not an Error instance | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------- | +| [83dcfb32f](https://github.com/angular/angular-cli/commit/83dcfb32f8ef3334f83bb36a2c3097fe9f8a4e4b) | fix | prevent numbers from class names | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------- | +| [ef6da4aad](https://github.com/angular/angular-cli/commit/ef6da4aad76ff534d4edb9e73c2d56c53b649b15) | fix | allow the esbuild-based builder to fully resolve global stylesheet packages | +| [eed54b359](https://github.com/angular/angular-cli/commit/eed54b359d2b514156242529ee8a25b51c50dae0) | fix | catch clause variable is not an Error instance | +| [c98471094](https://github.com/angular/angular-cli/commit/c9847109438d33d38a31ded20a1cab2721fc1fbd) | fix | correctly respond to preflight requests | +| [94b444e4c](https://github.com/angular/angular-cli/commit/94b444e4caff4c3092e0291d9109e2abed966656) | fix | correctly set `ngDevMode` in esbuilder | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------- | +| [44c18082a](https://github.com/angular/angular-cli/commit/44c18082a5963b7f9d0f1577a0975b2f35abe6a2) | fix | `classify` string util should concat string without using a `.` | + +### @angular/create + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [cb0d3fb33](https://github.com/angular/angular-cli/commit/cb0d3fb33f196393761924731c3c3786a3a3493b) | fix | use appropriate package manager to install dependencies | + +## Special Thanks + +Alan Agius, Charles Lyding, Jason Bedard and Paul Gschwendtner + + + + + +# 14.1.0 (2022-07-20) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [3884b8652](https://github.com/angular/angular-cli/commit/3884b865262c1ffa5652ac0f4d67bbf59087f453) | fix | add esbuild browser builder to workspace schema | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [707911d42](https://github.com/angular/angular-cli/commit/707911d423873623d4201d2fbce4a294ab73a135) | feat | support controlling `addDependency` utility rule install behavior | +| [a8fe4fcc3](https://github.com/angular/angular-cli/commit/a8fe4fcc315fd408b5b530a44a02c1655b5450a8) | fix | Allow skipping existing dependencies in E2E schematic | +| [b8bf3b480](https://github.com/angular/angular-cli/commit/b8bf3b480bef752641370e542ebb5aee649a8ac6) | fix | only issue a warning for addDependency existing specifier | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [a7709b718](https://github.com/angular/angular-cli/commit/a7709b718c953d83f3bde00fa3bf896501359946) | feat | add `externalDependencies` to the esbuild browser builder | +| [248860ad6](https://github.com/angular/angular-cli/commit/248860ad674b54f750bb5c197588bb6d031be208) | feat | add Sass file support to experimental esbuild-based builder | +| [b06ae5514](https://github.com/angular/angular-cli/commit/b06ae55140c01f8b5107527fd0af1da3b04a721f) | feat | add service worker support to experimental esbuild builder | +| [b5f6d862b](https://github.com/angular/angular-cli/commit/b5f6d862b95afd0ec42d9b3968e963f59b1b1658) | feat | Identify third-party sources in sourcemaps | +| [b3a14d056](https://github.com/angular/angular-cli/commit/b3a14d05629ba6e3b23c09b1bfdbc4b35d534813) | fix | allow third-party sourcemaps to be ignored in esbuild builder | +| [53dd929e5](https://github.com/angular/angular-cli/commit/53dd929e59f98a7088d150e861d18e97e6de4114) | fix | ensure esbuild builder sourcemap sources are relative to workspace | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [526cdb263](https://github.com/angular/angular-cli/commit/526cdb263a8c74ad228f584f70dc029aa69351d7) | feat | allow `chain` rule to accept iterables of rules | + +### @angular/create + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [cfe93fbc8](https://github.com/angular/angular-cli/commit/cfe93fbc89fad2f58826f0118ce7ff421cd0e4f2) | feat | add support for `yarn create` and `npm init` | + +## Special Thanks + +Alan Agius, Charles Lyding, Derek Cormier, Doug Parker, Jason Bedard, Joey Perrott, Paul Gschwendtner, Victor Porof and renovate[bot] + + + + + +# 14.0.7 (2022-07-20) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------- | +| [f653bf4fb](https://github.com/angular/angular-cli/commit/f653bf4fbb69b9e0fa0e6440a88a30f17566d9a3) | fix | incorrect logo for Angular Material | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------- | +| [5810c2cc2](https://github.com/angular/angular-cli/commit/5810c2cc2dd21e5922a5eaa330e854e4327a0500) | fix | fallback to use projectRoot when sourceRoot is missing during coverage | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------- | +| [2ba4678b6](https://github.com/angular/angular-cli/commit/2ba4678b6ba2164e80cb661758565c133e08afaa) | fix | add i18n as valid project extension | +| [c2201c835](https://github.com/angular/angular-cli/commit/c2201c835801ef9c1cc6cacec2748c8ca341519d) | fix | log name of invalid extension too | + +## Special Thanks + +Alan Agius, Fortunato Ventre, Katerina Skroumpelou and Kristiyan Kostadinov + + + + + +# 13.3.9 (2022-07-20) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------- | +| [0d62716ae](https://github.com/angular/angular-cli/commit/0d62716ae3753bb463de6b176ae07520ebb24fc9) | fix | update terser to address CVE-2022-25858 | + +## Special Thanks + +Alan Agius and Charles Lyding + + + + + +# 14.0.6 (2022-07-13) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------- | +| [178550529](https://github.com/angular/angular-cli/commit/1785505290940dad2ef9a62d4725e0d1b4b486d4) | fix | handle cases when completion is enabled and running in an older CLI workspace | +| [10f24498e](https://github.com/angular/angular-cli/commit/10f24498ec2938487ae80d6ecea584e20b01dcbe) | fix | remove deprecation warning of `no` prefixed schema options | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------- | +| [dfa6d73c5](https://github.com/angular/angular-cli/commit/dfa6d73c5c45d3c3276fb1fecfb6535362d180c5) | fix | remove browserslist configuration | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------- | +| [4d848c4e6](https://github.com/angular/angular-cli/commit/4d848c4e6f6944f32b9ecb2cf2db5c544b3894fe) | fix | generate different content hashes for scripts which are changed during the optimization phase | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [2500f34a4](https://github.com/angular/angular-cli/commit/2500f34a401c2ffb03b1dfa41299d91ddebe787e) | fix | provide actionable warning when a workspace project has missing `root` property | + +## Special Thanks + +Alan Agius and martinfrancois + + + + + +# 14.0.5 (2022-07-06) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------ | +| [98a6aad60](https://github.com/angular/angular-cli/commit/98a6aad60276960bd6bcecda73172480e4bdec48) | fix | during an update only use package manager force option with npm 7+ | +| [094aa16aa](https://github.com/angular/angular-cli/commit/094aa16aaf5b148f2ca94cae45e18dbdeaacad9d) | fix | improve error message for project-specific ng commands when run outside of a project | +| [e5e07fff1](https://github.com/angular/angular-cli/commit/e5e07fff1919c46c15d6ce61355e0c63007b7d55) | fix | show deprecated workspace config options in IDE | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [f9f970cab](https://github.com/angular/angular-cli/commit/f9f970cab515a8a1b1fbb56830b03250dd5cccce) | fix | prevent importing `RouterModule` parallel to `RoutingModule` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [aa8ed532f](https://github.com/angular/angular-cli/commit/aa8ed532f816f2fa23b1fe443a216c5d75507432) | fix | disable glob mounting for patterns that start with a forward slash | +| [c76edb8a7](https://github.com/angular/angular-cli/commit/c76edb8a79d1a12376c2a163287251c06e1f0222) | fix | don't override base-href in HTML when it's not set in builder | +| [f64903528](https://github.com/angular/angular-cli/commit/f649035286d640660c3bc808b7297fb60d0888bc) | fix | improve detection of CommonJS dependencies | +| [74dbd5fc2](https://github.com/angular/angular-cli/commit/74dbd5fc273aece097b2b3ee0b28607d24479d8c) | fix | support hidden component stylesheet sourcemaps with esbuild builder | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [7aed97561](https://github.com/angular/angular-cli/commit/7aed97561c2320f92f8af584cc9852d4c8d818b9) | fix | do not run ngcc when `node_modules` does not exist | + +## Special Thanks + +Alan Agius, Charles Lyding, JoostK and Paul Gschwendtner + + + + + +# 14.0.4 (2022-06-29) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [fc72c625b](https://github.com/angular/angular-cli/commit/fc72c625bb7db7b9c8d865086bcff05e2db426ee) | fix | correctly handle `--collection` option in `ng new` | +| [f5badf221](https://github.com/angular/angular-cli/commit/f5badf221d2a2f5357f93bf0e32146669f8bbede) | fix | improve global schema validation | +| [ed302ea4c](https://github.com/angular/angular-cli/commit/ed302ea4c80b4f6fe8a73c5a0d25055a7dca1db2) | fix | remove color from help epilogue | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [c58c66c0d](https://github.com/angular/angular-cli/commit/c58c66c0d5c76630453151b65b1a1c3707c82e9f) | fix | use `sourceRoot` instead of `src` in universal schematic | + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------- | +| [88acec1fd](https://github.com/angular/angular-cli/commit/88acec1fd302d7d8a053e37ed0334ec6a30c952c) | fix | complete builders on the next event loop iteration | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------- | +| [694b73dfa](https://github.com/angular/angular-cli/commit/694b73dfa12e5aefff8fc5fdecf220833ac40b42) | fix | exit dev-server when CTRL+C is pressed | +| [6d4782199](https://github.com/angular/angular-cli/commit/6d4782199c4a4e92a9c0b189d6a7857ca631dd3f) | fix | exit localized builds when CTRL+C is pressed | +| [282baffed](https://github.com/angular/angular-cli/commit/282baffed507926e806db673b6804b9299c383af) | fix | hide stacktraces from webpack errors | +| [c4b0abf5b](https://github.com/angular/angular-cli/commit/c4b0abf5b8c1e392ead84c8810e8d6e615fd0024) | fix | set base-href in service worker manifest when using i18n and app-shell | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [33f1cc192](https://github.com/angular/angular-cli/commit/33f1cc192d963b4a4348bb41b8fb0969ffd5c342) | fix | restore process title after NGCC is executed | +| [6796998bf](https://github.com/angular/angular-cli/commit/6796998bf4dd829f9ac085a52ce7e9d2cda73fd1) | fix | show a compilation error on invalid TypeScript version | + +## Special Thanks + +Alan Agius, Charles Lyding and Tim Bowersox + + + + + +# 14.0.3 (2022-06-23) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------- | +| [b3db91baf](https://github.com/angular/angular-cli/commit/b3db91baf50c92589549a66ffef437f7890d3de7) | fix | disable version check when running `ng completion` commands | +| [cdab9fa74](https://github.com/angular/angular-cli/commit/cdab9fa7431db7e2a75e04e776555b8e5e15fc94) | fix | provide an actionable error when using `--configuration` with `ng run` | +| [5521648e3](https://github.com/angular/angular-cli/commit/5521648e33af634285f6352b43a324a1ee023e27) | fix | temporarily handle boolean options in schema prefixed with `no` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [5e960ce24](https://github.com/angular/angular-cli/commit/5e960ce246e7090f57ce22723911a743aa8fcb0c) | fix | fix incorrect glob cwd in karma when using `--include` option | +| [1b5e92075](https://github.com/angular/angular-cli/commit/1b5e92075e64563459942d4de785f1a8bef46ec7) | fix | handle `codeCoverageExclude` correctly in Windows | +| [ff6d81a45](https://github.com/angular/angular-cli/commit/ff6d81a4539657446c8f5770cefe688d2d578450) | fix | ignore supported browsers during i18n extraction | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [170c16f2e](https://github.com/angular/angular-cli/commit/170c16f2ea769e76a48f1ac215ee88ba47ff511d) | fix | workspace writer skip creating empty projects property | + +## Special Thanks + +Alan Agius, Charles Lyding and Paul Gschwendtner + + + + + +# 14.0.2 (2022-06-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [23095e9c3](https://github.com/angular/angular-cli/commit/23095e9c3fc514c7e9a892833d8a18270da5bd95) | fix | show more actionable error when command is ran in wrong scope | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [5a486cb64](https://github.com/angular/angular-cli/commit/5a486cb64253ba2829160a6f1fa3bf0e381d45ea) | fix | remove vscode testing configurations for `minimal` workspaces | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------- | +| [9d88c96d8](https://github.com/angular/angular-cli/commit/9d88c96d898c5c46575a910a7230d239f4fe7a77) | fix | replace fallback locale for `en-US` | + +## Special Thanks + +Alan Agius and Julien Marcou + + + + + +# 13.3.8 (2022-06-15) + +### @angular/pwa + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------- | +| [c7f994f88](https://github.com/angular/angular-cli/commit/c7f994f88a396be96c01da1017a15083d5f544fb) | fix | add peer dependency on Angular CLI | + +## Special Thanks + +Alan Agius + + + + + +# 14.0.1 (2022-06-08) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------- | +| [e4fb96657](https://github.com/angular/angular-cli/commit/e4fb96657f044d97562008b5b3c6f3a55ac8ba3a) | fix | add text to help output to indicate that additional commands are available when ran in different context | +| [7952e5790](https://github.com/angular/angular-cli/commit/7952e579066f7191f4b82a10816c6a41a4ea5644) | fix | avoid creating unnecessary global configuration | +| [66a1d6b9d](https://github.com/angular/angular-cli/commit/66a1d6b9d2e1fba3d5ee88a6c5d81206f530ce3a) | fix | correct scope cache command | +| [e2d964289](https://github.com/angular/angular-cli/commit/e2d964289fe2a418e5f4e421249e2f8da64185cc) | fix | correctly print package manager name when an install is needed | +| [75fd3330d](https://github.com/angular/angular-cli/commit/75fd3330d4c27263522ea931eb1545ce0a34ab6a) | fix | during an update only use package manager force option with npm 7+ | +| [e223890c1](https://github.com/angular/angular-cli/commit/e223890c1235b4564ec15eb99d71256791a21c3c) | fix | ensure full process exit with older local CLI versions | +| [0cca3638a](https://github.com/angular/angular-cli/commit/0cca3638adb46cd5d0c18b823c83d4b604d7c798) | fix | handle project being passed as a flag | +| [b1451cb5e](https://github.com/angular/angular-cli/commit/b1451cb5e90f43df365202a6fdfcfbc9e0853ca4) | fix | improve resilience of logging during process exit | +| [17fec1357](https://github.com/angular/angular-cli/commit/17fec13577ac333fc66c3752c75be58146c9ebac) | fix | provide actionable error when project cannot be determined | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| [73dcf39c6](https://github.com/angular/angular-cli/commit/73dcf39c6e7678a3915a113fd72829549ccc3b8e) | fix | remove strict setting under application project | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [c788d5b56](https://github.com/angular/angular-cli/commit/c788d5b56a1a191e7ca53c3b63245e3979a1cf44) | fix | log modified and removed files when using the `verbose` option | +| [6e8fe0ed5](https://github.com/angular/angular-cli/commit/6e8fe0ed54d88132da0238fdb3a6e97330c85ff7) | fix | replace dev-server socket path from `/ws` to `/ng-cli-ws` | +| [651adadf4](https://github.com/angular/angular-cli/commit/651adadf4df8b66c60771f27737cb2a67957b46a) | fix | update Angular peer dependencies to 14.0 stable | + +### @angular/pwa + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------- | +| [cfd264d06](https://github.com/angular/angular-cli/commit/cfd264d061109c7989933e51a14b6bf83b289b07) | fix | add peer dependency on Angular CLI | + +## Special Thanks + +Alan Agius, Charles Lyding and Doug Parker + + + + + +# 14.0.0 (2022-06-02) + +## Breaking Changes + +### @angular/cli + +- Several changes to the `ng analytics` command syntax. + - `ng analytics project ` has been replaced with `ng analytics ` + - `ng analytics ` has been replaced with `ng analytics --global` + +- Support for Node.js v12 has been removed as it will become EOL on 2022-04-30. Please use Node.js v14.15 or later. +- Support for TypeScript 4.4 and 4.5 has been removed. Please update to TypeScript 4.6. +- `--all` option from `ng update` has been removed without replacement. To update packages which don’t provide `ng update` capabilities in your workspace `package.json` use `npm update`, `yarn upgrade-interactive` or `yarn upgrade` instead. +- Deprecated option `--prod` has been removed from all builders. `--configuration production`/`-c production` should be used instead if the default configuration of the builder is not configured to `production`. +- `--configuration` cannot be used with `ng run`. Provide the configuration as part of the target. Ex: `ng run project:builder:configuration`. +- Deprecated `ng x18n` and `ng i18n-extract` commands have been removed in favor of `ng extract-i18n`. +- Several changes in the Angular CLI commands and arguments handling. + - `ng help` has been removed in favour of the `—-help` option. + - `ng —-version` has been removed in favour of `ng version` and `ng v`. + - Deprecated camel cased arguments are no longer supported. Ex. using `—-sourceMap` instead of `—-source-map` will result in an error. + - `ng update`, `—-migrate-only` option no longer accepts a string of migration name, instead use `—-migrate-only -—name `. + - `—-help json` help has been removed. + +### @angular-devkit/architect-cli + +- camel case arguments are no longer allowed. + +### @angular-devkit/schematics-cli + +- camel case arguments are no longer allowed. + +### @angular-devkit/build-angular + +- `browser` and `karma` builders `script` and `styles` options input files extensions are now validated. + + Valid extensions for `scripts` are: + - `.js` + - `.cjs` + - `.mjs` + - `.jsx` + - `.cjsx` + - `.mjsx` + + Valid extensions for `styles` are: + - `.css` + - `.less` + - `.sass` + - `.scss` + - `.styl` + +- We now issue a build time error since importing a CSS file as an ECMA module is non standard Webpack specific feature, which is not supported by the Angular CLI. + + This feature was never truly supported by the Angular CLI, but has as such for visibility. + +- Reflect metadata polyfill is no longer automatically provided in JIT mode + Reflect metadata support is not required by Angular in JIT applications compiled by the CLI. + Applications built in AOT mode did not and will continue to not provide the polyfill. + For the majority of applications, the reflect metadata polyfill removal should have no effect. + However, if an application uses JIT mode and also uses the previously polyfilled reflect metadata JavaScript APIs, the polyfill will need to be manually added to the application after updating. + To replicate the previous behavior, the `core-js` package should be manually installed and the `import 'core-js/proposals/reflect-metadata';` statement should be added to the application's `polyfills.ts` file. +- `NG_BUILD_CACHE` environment variable has been removed. `cli.cache` in the workspace configuration should be used instead. +- The deprecated `showCircularDependencies` browser and server builder option has been removed. The recommended method to detect circular dependencies in project code is to use either a lint rule or other external tools. + +### @angular-devkit/core + +- `parseJson` and `ParseJsonOptions` APIs have been removed in favor of 3rd party JSON parsers such as `jsonc-parser`. +- The below APIs have been removed without replacement. Users should leverage other Node.js or other APIs. + - `fs` namespace + - `clean` + - `mapObject` + +### @angular-devkit/schematics + +- Schematics `NodePackageInstallTask` will not execute package scripts by default + The `NodePackageInstallTask` will now use the package manager's `--ignore-scripts` option by default. + The `--ignore-scripts` option will prevent package scripts from executing automatically during an install. + If a schematic installs packages that need their `install`/`postinstall` scripts to be executed, the + `NodePackageInstallTask` now contains an `allowScripts` boolean option which can be enabled to provide the + previous behavior for that individual task. As with previous behavior, the `allowScripts` option will + prevent the individual task's usage of the `--ignore-scripts` option but will not override the package + manager's existing configuration. +- Deprecated `analytics` property has been removed from `TypedSchematicContext` interface + +### @ngtools/webpack + +- `ivy` namespace has been removed from the public API. + - `ivy.AngularWebpackPlugin` -> `AngularWebpackPlugin` + - `ivy.AngularPluginOptions` -> `AngularPluginOptions` + +## Deprecations + +### @angular/cli + +- The `defaultCollection` workspace option has been deprecated in favor of `schematicCollections`. + + Before + + ```json + "defaultCollection": "@angular/material" + ``` + + After + + ```json + "schematicCollections": ["@angular/material"] + ``` + +- The `defaultProject` workspace option has been deprecated. The project to use will be determined from the current working directory. + +### @angular-devkit/core + +- - `ContentHasMutatedException`, `InvalidUpdateRecordException`, `UnimplementedException` and `MergeConflictException` symbol from `@angular-devkit/core` have been deprecated in favor of the symbol from `@angular-devkit/schematics`. + - `UnsupportedPlatformException` - A custom error exception should be created instead. + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------- | +| [afafa5788](https://github.com/angular/angular-cli/commit/afafa5788f11b8727c39bb0a390300a706aba5bc) | feat | add `--global` option to `ng analytics` command | +| [bb550436a](https://github.com/angular/angular-cli/commit/bb550436a476d74705742a8c36f38971b346b903) | feat | add `ng analytics info` command | +| [e5bf35ea3](https://github.com/angular/angular-cli/commit/e5bf35ea3061a3e532aa85df44551107e62e24c5) | feat | add `ng cache` command | +| [7ab22ed40](https://github.com/angular/angular-cli/commit/7ab22ed40d521e3cec29ab2d66d0289c3cdb4106) | feat | add disable/enable aliases for off/on `ng analytics` command | +| [4212fb8de](https://github.com/angular/angular-cli/commit/4212fb8de2f4f3e80831a0803acc5fc6e54db1e1) | feat | add prompt to set up CLI autocompletion | +| [0316dea67](https://github.com/angular/angular-cli/commit/0316dea676be522b04d654054880cc5794e3c8b3) | feat | add prompts on missing builder targets | +| [607a723f7](https://github.com/angular/angular-cli/commit/607a723f7d623ec8a15054722b2afd13042f66a1) | feat | add support for auto completion | +| [366cabc66](https://github.com/angular/angular-cli/commit/366cabc66c3dd836e2fdfea8dad6c4c7c2096b1d) | feat | add support for multiple schematics collections | +| [036327e9c](https://github.com/angular/angular-cli/commit/036327e9ca838f9ef3f117fbd18949d9d357e68d) | feat | deprecated `defaultProject` option | +| [fb0622893](https://github.com/angular/angular-cli/commit/fb06228932299870774a7b254f022573f5d8175f) | feat | don't prompt to set up autocompletion for `ng update` and `ng completion` commands | +| [4ebfe0341](https://github.com/angular/angular-cli/commit/4ebfe03415ebe4e8f1625286d1be8bd1b54d3862) | feat | drop support for Node.js 12 | +| [022d8c7bb](https://github.com/angular/angular-cli/commit/022d8c7bb142e8b83f9805a39bc1ae312da465eb) | feat | make `ng completion` set up CLI autocompletion by modifying `.bashrc` files | +| [2e15df941](https://github.com/angular/angular-cli/commit/2e15df9417dcc47b12785a8c4c9074bf05d0450c) | feat | remember after prompting users to set up autocompletion and don't prompt again | +| [7fa3e6587](https://github.com/angular/angular-cli/commit/7fa3e6587955d0638929758d3c257392c242c796) | feat | support TypeScript 4.6.2 | +| [9e69331fa](https://github.com/angular/angular-cli/commit/9e69331fa61265c77d6281232bb64a2c63509290) | feat | use PNPM as package manager when `pnpm-lock.yaml` exists | +| [6f6b453fb](https://github.com/angular/angular-cli/commit/6f6b453fbf90adad16eba7ea8929a11235c1061b) | fix | `ng doc` doesn't open browser in Windows | +| [8e66c9188](https://github.com/angular/angular-cli/commit/8e66c9188be827380e5acda93c7e21fae718b9ce) | fix | `ng g` show description from `collection.json` if not present in `schema.json` | +| [9edeb8614](https://github.com/angular/angular-cli/commit/9edeb86146131878c5e8b21b6adaa24a26f12453) | fix | add long description to `ng update` | +| [160cb0718](https://github.com/angular/angular-cli/commit/160cb071870602d9e7fece2ce381facb71e7d762) | fix | correctly handle `--search` option in `ng doc` | +| [d46cf6744](https://github.com/angular/angular-cli/commit/d46cf6744eadb70008df1ef25e24fb1db58bb997) | fix | display option descriptions during auto completion | +| [09f8659ce](https://github.com/angular/angular-cli/commit/09f8659cedcba70903140d0c3eb5d0e10ebb506c) | fix | display package manager during `ng update` | +| [a49cdfbfe](https://github.com/angular/angular-cli/commit/a49cdfbfefbdd756882be96fb61dc8a0d374b6e0) | fix | don't prompt for analytics when running `ng analytics` | +| [4b22593c4](https://github.com/angular/angular-cli/commit/4b22593c4a269ea4bd63cef39009aad69f159fa1) | fix | ensure all available package migrations are executed | +| [054ae02c2](https://github.com/angular/angular-cli/commit/054ae02c2fb8eed52af76cf39a432a3770d301e4) | fix | favor project in cwd when running architect commands | +| [ff4eba3d4](https://github.com/angular/angular-cli/commit/ff4eba3d4a9417d2baef70aaa953bdef4bb426a6) | fix | handle duplicate arguments | +| [5a8bdeb43](https://github.com/angular/angular-cli/commit/5a8bdeb434c7561334bfc8865ed279110a44bd93) | fix | hide private schematics from `ng g` help output | +| [644f86d55](https://github.com/angular/angular-cli/commit/644f86d55b75a289e641ba280e8456be82383b06) | fix | improve error message for Windows autocompletion use cases | +| [3012036e8](https://github.com/angular/angular-cli/commit/3012036e81fc6e5fc6c0f1df7ec626f91285673e) | fix | populate path with working directory in nested schematics | +| [8a396de6a](https://github.com/angular/angular-cli/commit/8a396de6a8a58347d2201a43d7f5101f94f20e89) | fix | print entire config when no positional args are provided to `ng config` | +| [bdf2b9bfa](https://github.com/angular/angular-cli/commit/bdf2b9bfa9893a940ba254073d024172e0dc1abc) | fix | print schematic errors correctly | +| [efc3c3225](https://github.com/angular/angular-cli/commit/efc3c32257a65caf36999dc34cadc41eedcbf323) | fix | remove analytics prompt postinstall script | +| [bf15b202b](https://github.com/angular/angular-cli/commit/bf15b202bb1cd073fe01cf387dce2c033b5bb14c) | fix | remove cache path from global valid paths | +| [142da460b](https://github.com/angular/angular-cli/commit/142da460b22e07a5a37b6140b50663446c3a2dbf) | fix | remove incorrect warning during `ng update` | +| [96a0d92da](https://github.com/angular/angular-cli/commit/96a0d92da2903edfb3835ce86b3700629d6e43ad) | fix | remove JSON serialized description from help output | +| [78460e995](https://github.com/angular/angular-cli/commit/78460e995a192336db3c4be9d0592b4e7a2ff2c8) | fix | remove type casting and add optional chaining for current in optionTransforms | +| [e5bdadac4](https://github.com/angular/angular-cli/commit/e5bdadac44ac023363bc0a2473892fc17430b81f) | fix | skip prompt or warn when setting up autocompletion without a global CLI install | +| [ca401255f](https://github.com/angular/angular-cli/commit/ca401255f49568cfe5f9ec6a35ea5b91c91afa70) | fix | sort commands in help output | +| [b97772dfc](https://github.com/angular/angular-cli/commit/b97772dfc03401fe1faa79e77742905341bd5d46) | fix | support silent package installs with Yarn 2+ | +| [87cd5cd43](https://github.com/angular/angular-cli/commit/87cd5cd4311e71a15ea1ecb82dde7480036cb815) | fix | workaround npm 7+ peer dependency resolve errors during updates | +| [d94a67353](https://github.com/angular/angular-cli/commit/d94a67353dcdaa30cf5487744a7ef151a6268f2d) | refactor | remove deprecated `--all` option from `ng update` | +| [2fc7c73d7](https://github.com/angular/angular-cli/commit/2fc7c73d7e40dbb0a593df61eeba17c8a8f618a9) | refactor | remove deprecated `--prod` flag | +| [b69ca3a7d](https://github.com/angular/angular-cli/commit/b69ca3a7d22b54fc06fbc1cfb559b2fd915f5609) | refactor | remove deprecated command aliases for `extract-i18n`. | +| [2e0493130](https://github.com/angular/angular-cli/commit/2e0493130acfe7244f7ee3ef28c961b1b04d7722) | refactor | replace command line arguments parser | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [7b78b7840](https://github.com/angular/angular-cli/commit/7b78b7840e95b0f4dca2fcb9218b67dd7500ff2c) | feat | add --standalone to ng generate | +| [e49220fba](https://github.com/angular/angular-cli/commit/e49220fba0d158be0971989e26eb199ec02fa113) | feat | add migratiom to remove `defaultProject` in workspace config | +| [3fa38b08b](https://github.com/angular/angular-cli/commit/3fa38b08ba8ef57a6079873223a7d6088d5ea64e) | feat | introduce `addDependency` rule to utilities | +| [b07ccfbb1](https://github.com/angular/angular-cli/commit/b07ccfbb1b2045d285c23dd4b654e1380892fcb2) | feat | introduce a utility subpath export for Angular rules and utilities | +| [7e7de6858](https://github.com/angular/angular-cli/commit/7e7de6858dd71bd461ceb0f89e29e2c57099bbcc) | feat | update Angular dependencies to use `^` as version prefix | +| [69ecddaa7](https://github.com/angular/angular-cli/commit/69ecddaa7d8b01aa7a9e61c403a4b9a8669e34c4) | feat | update new and existing projects compilation target to `ES2020` | +| [7e8e42063](https://github.com/angular/angular-cli/commit/7e8e42063f354c402d758f10c8ba9bee7e0c8aff) | fix | add migration to remove `package.json` in libraries secondary entrypoints | +| [b928d973e](https://github.com/angular/angular-cli/commit/b928d973e97f33220afe16549b41c4031feb5c5e) | fix | alphabetically order imports during component generation | +| [09a71bab6](https://github.com/angular/angular-cli/commit/09a71bab6044e517319f061dbd4555ce57fe6485) | fix | Consolidated setup with a single `beforeEach()` | +| [1921b07ee](https://github.com/angular/angular-cli/commit/1921b07eeb710875825dc6f7a4452bd5462e6ba7) | fix | don't add path mapping to old entrypoint definition file | +| [c927c038b](https://github.com/angular/angular-cli/commit/c927c038ba356732327a026fe9a4c36ed23c9dec) | fix | remove `@types/node` from new projects | +| [27cb29438](https://github.com/angular/angular-cli/commit/27cb29438aa01b185b2dca3617100d87f45f14e8) | fix | remove extra space in standalone imports | + +### @angular-devkit/architect-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | -------------------------------- | +| [c7556b62b](https://github.com/angular/angular-cli/commit/c7556b62b7b0eab5717ed6eeab3fa7f0f1f2a873) | refactor | replace parser with yargs-parser | + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | -------------------------------- | +| [5330d52ae](https://github.com/angular/angular-cli/commit/5330d52aee32daca27fa1a2fa15712f4a408602a) | refactor | replace parser with yargs-parser | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------ | +| [00186fb93](https://github.com/angular/angular-cli/commit/00186fb93f66d8da51886de37cfa4599f3e89af9) | feat | add initial experimental esbuild-based application browser builder | +| [d23a168b8](https://github.com/angular/angular-cli/commit/d23a168b8d558ae9d73c8c9eed4ff199fc4d74b9) | feat | validate file extensions for `scripts` and `styles` options | +| [2adf252dc](https://github.com/angular/angular-cli/commit/2adf252dc8a7eb0ce504de771facca56730e5272) | fix | add es2015 exports package condition to browser-esbuild | +| [72e820e7b](https://github.com/angular/angular-cli/commit/72e820e7b2bc6904b030f1092bbb610334a4036f) | fix | better handle Windows paths in esbuild experimental builder | +| [587082fb0](https://github.com/angular/angular-cli/commit/587082fb0fa7bdb6cddb36327f791889d76e3e7b) | fix | close compiler on Karma exit | +| [c52d10d1f](https://github.com/angular/angular-cli/commit/c52d10d1fc4b70483a2043edfa73dc0f323f6bf1) | fix | close dev-server on error | +| [48630ccfd](https://github.com/angular/angular-cli/commit/48630ccfd7a672fc5174ef484b3bd5c549d32fef) | fix | detect `tailwind.config.cjs` as valid tailwindcss configuration | +| [4d5f6c659](https://github.com/angular/angular-cli/commit/4d5f6c65918c1a8a4bde0a0af01089242d1cdc4a) | fix | downlevel libraries based on the browserslist configurations | +| [1a160dac0](https://github.com/angular/angular-cli/commit/1a160dac00f34aab089053281c640dba3efd597f) | fix | ensure karma sourcemap support on Windows | +| [07e776ea3](https://github.com/angular/angular-cli/commit/07e776ea379a50a98a50cf590156c2dc1b272e78) | fix | fail build when importing CSS files as an ECMA modules | +| [ac1383f9e](https://github.com/angular/angular-cli/commit/ac1383f9e5d491181812c090bd4323f46110f3d8) | fix | properly handle locally-built APF v14 libraries | +| [966d25b55](https://github.com/angular/angular-cli/commit/966d25b55eeb6cb84eaca183b30e7d3b0d0a2188) | fix | remove unneeded JIT reflect metadata polyfill | +| [b8564a638](https://github.com/angular/angular-cli/commit/b8564a638df3b6971ef2ac8fb838e6a7c910ac3b) | refactor | remove deprecated `NG_BUILD_CACHE` environment variable | +| [0a1cd584d](https://github.com/angular/angular-cli/commit/0a1cd584d8ed00889b177f4284baec7e5427caf2) | refactor | remove deprecated `showCircularDependencies` browser and server builder option | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------- | +| [c5b3e9299](https://github.com/angular/angular-cli/commit/c5b3e9299130132aecfa19219405e1964d0c5443) | refactor | deprecate unused exception classes | +| [67144b9e5](https://github.com/angular/angular-cli/commit/67144b9e54b5a9bfbc963e386b01275be5eaccf5) | refactor | remove deprecated `parseJson` and `ParseJsonOptions` APIs | +| [a0c02af7e](https://github.com/angular/angular-cli/commit/a0c02af7e340bb16f4e6f523c2d835c9b18926b3) | refactor | remove deprecated fs, object and array APIs | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------- | +| [c9c781c7d](https://github.com/angular/angular-cli/commit/c9c781c7d5f3c6de780912fd7c624a457e6da14c) | feat | add parameter to `listSchematicNames` to allow returning hidden schematics. | +| [0e6425fd8](https://github.com/angular/angular-cli/commit/0e6425fd88ea32679516251efdca6ff07cc4b56a) | feat | disable package script execution by default in `NodePackageInstallTask` | +| [25498ad5b](https://github.com/angular/angular-cli/commit/25498ad5b2ba6fa5a88c9802ddeb0ed85c5d9b60) | feat | re-export core string helpers from schematics package | +| [464cf330a](https://github.com/angular/angular-cli/commit/464cf330a14397470e1e57450a77f421a45a927e) | feat | support null for options parameter from OptionTransform type | +| [33f9f3de8](https://github.com/angular/angular-cli/commit/33f9f3de869bba2ecd855a01cc9a0a36651bd281) | feat | support reading JSON content directly from a Tree | +| [01297f450](https://github.com/angular/angular-cli/commit/01297f450387dea02eafd6f5701c417ab5c5d844) | feat | support reading text content directly from a Tree | +| [48f9b79bc](https://github.com/angular/angular-cli/commit/48f9b79bc4d43d0180bab5af5726621a68204a15) | fix | support ignore scripts package installs with Yarn 2+ | +| [3471cd6d8](https://github.com/angular/angular-cli/commit/3471cd6d8696ae9c28dba901d3e0f6868d69efc8) | fix | support quiet package installs with Yarn 2+ | +| [44c1e6d0d](https://github.com/angular/angular-cli/commit/44c1e6d0d2db5f2dc212d63a34ade045cb7854d5) | refactor | remove deprecated `analytics` property | + +### @angular/pwa + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [243cb4062](https://github.com/angular/angular-cli/commit/243cb40622fef4107b0162bc7b6a374471cebc14) | fix | remove `@schematics/angular` utility deep import usage | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------ | +| [0c344259d](https://github.com/angular/angular-cli/commit/0c344259dcdc10a35840151bfe3ae1b27f9b53ff) | fix | update peer dependency to reflect TS 4.6 support | +| [044101554](https://github.com/angular/angular-cli/commit/044101554dfbca07d74f2a4391f94875df7928d2) | perf | use Webpack's built-in xxhash64 support | +| [9277eed1d](https://github.com/angular/angular-cli/commit/9277eed1d9603d5e258eb7ae27de527eba919482) | refactor | remove deprecated ivy namespace | + +## Special Thanks + +Adrien Crivelli, Alan Agius, Charles Lyding, Cédric Exbrayat, Daniil Dubrava, Doug Parker, Elton Coelho, George Kalpakas, Jason Bedard, Joey Perrott, Kristiyan Kostadinov, Paul Gschwendtner, Pawel Kozlowski, Tobias Speicher and alkavats1 + + + + + +# 13.3.7 (2022-05-25) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [a54018d8f](https://github.com/angular/angular-cli/commit/a54018d8f5f976034bf0a33f826245b7a6b74bbe) | fix | add debugging and timing information in JavaScript and CSS optimization plugins | + +## Special Thanks + +Alan Agius and Joey Perrott + + + + + +# 13.3.6 (2022-05-18) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------- | +| [e20964c43](https://github.com/angular/angular-cli/commit/e20964c43c52125b6d2bfa9bbea444fb2eea1e15) | fix | resolve relative schematic from `angular.json` instead of current working directory | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------ | +| [16fec8d58](https://github.com/angular/angular-cli/commit/16fec8d58b6ec421df5e7809c45838baf232b4a9) | fix | update `babel-loader` to 8.2.5 | + +## Special Thanks + +Alan Agius, Charles Lyding, Jason Bedard and Paul Gschwendtner + + + + + +# 13.3.5 (2022-05-04) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------- | +| [6da0910d3](https://github.com/angular/angular-cli/commit/6da0910d345eb84084e32a462432a508d518f402) | fix | update `@ampproject/remapping` to `2.2.0` | + +## Special Thanks + +Alan Agius, Charles Lyding and Paul Gschwendtner + + + + + +# 13.3.4 (2022-04-27) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------- | +| [f4da75656](https://github.com/angular/angular-cli/commit/f4da756560358273098df2a5cae7848201206c77) | fix | change wrapping of schematic code | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [5d0141bfb](https://github.com/angular/angular-cli/commit/5d0141bfb4ae80b1a7543eab64e9c381c932eaef) | fix | correctly resolve custom service worker configuration file | + +## Special Thanks + +Charles Lyding and Wagner Maciel + + + + + +# 13.3.3 (2022-04-13) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [d38b247cf](https://github.com/angular/angular-cli/commit/d38b247cf19edf5ecf7792343fa2bc8c05a3a8b8) | fix | display debug logs when using the `--verbose` option | + +### @angular-devkit/build-webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------- | +| [5682baee4](https://github.com/angular/angular-cli/commit/5682baee4b562b314dad781403dcc0c46e0a8abb) | fix | emit devserver setup errors | + +## Special Thanks + +Alan Agius + + + + + +# 13.3.2 (2022-04-06) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [49dc63d09](https://github.com/angular/angular-cli/commit/49dc63d09a7a7f2b7759b47e79fac934b867e9b4) | fix | ensure lint command auto-add exits after completion | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [bbe74b87e](https://github.com/angular/angular-cli/commit/bbe74b87e52579c06b911db6173f33c67b8010a6) | fix | provide actionable error message when routing declaration cannot be found | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [c97c8e7c9](https://github.com/angular/angular-cli/commit/c97c8e7c9bbcad66ba80967681cac46042c3aca7) | fix | update `minimatch` dependency to `3.0.5` | + +## Special Thanks + +Alan Agius, Charles Lyding and Morga Cezary + + + + + +# 13.3.1 (2022-03-30) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------- | +| [cf3cb2ecf](https://github.com/angular/angular-cli/commit/cf3cb2ecf9ca47a984c4272f0094f2a1c68c7dfe) | fix | fix extra comma added when use --change-detection=onPush and --style=none to generate a component | + +### @angular-devkit/architect-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [9f8d4dea0](https://github.com/angular/angular-cli/commit/9f8d4dea0449e236de7b928c5cc97e597a6f5844) | fix | update `minimist` to `1.2.6` | + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [ba3486de9](https://github.com/angular/angular-cli/commit/ba3486de94e733addf0ac17706b806dd813c9046) | fix | update `minimist` to `1.2.6` | + +### @angular-devkit/benchmark + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [1f7fa6970](https://github.com/angular/angular-cli/commit/1f7fa6970e8cddb2ba0c42df0e048a57292b7fe8) | fix | update `minimist` to `1.2.6` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [293526c31](https://github.com/angular/angular-cli/commit/293526c31db9f0becc0ffc2d60999c80afa8a308) | fix | add `node_modules` prefix to excludes RegExp | +| [58ed97410](https://github.com/angular/angular-cli/commit/58ed97410b760909d523b05c3b4a06364e3c9a0f) | fix | allow Workers in Stackblitz | +| [4cd2331d3](https://github.com/angular/angular-cli/commit/4cd2331d34e2a9ab2ed78edf0284dbfefef511a5) | fix | don't override asset info when updating assets | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [c7c75820f](https://github.com/angular/angular-cli/commit/c7c75820f1d4ef827336626b78c8c3e5c0bd1f00) | fix | add Angular CLI major version as analytics dimension | + +## Special Thanks + +Alan Agius and gauravsoni119 + + + + + +# 12.2.17 (2022-03-31) + +### @angular-devkit/architect-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [ccb0f95f3](https://github.com/angular/angular-cli/commit/ccb0f95f33ff0d23a0ff9b237d0d78fc4c864787) | fix | update `minimist` to `1.2.6` | + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [abcdf4df2](https://github.com/angular/angular-cli/commit/abcdf4df20c29907ee28a38842942464addcf259) | fix | update `minimist` to `1.2.6` | + +### @angular-devkit/benchmark + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [2656a330e](https://github.com/angular/angular-cli/commit/2656a330eb365f37c3b6f8894436b4449d157e63) | fix | update `minimist` to `1.2.6` | + +## Special Thanks + +Alan Agius + + + + + +# 11.2.19 (2022-03-30) + +### @angular-devkit/architect-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [75caa1143](https://github.com/angular/angular-cli/commit/75caa1143f4007c9550ab0dabb62ae4df91e3827) | fix | update `minimist` to `1.2.6` | + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [80d479e9f](https://github.com/angular/angular-cli/commit/80d479e9fdfcf6863ebbe0986ea6cd29309f398d) | fix | update `minimist` to `1.2.6` | + +### @angular-devkit/benchmark + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------- | +| [f61cd1a79](https://github.com/angular/angular-cli/commit/f61cd1a79b6960711d4aa5b16d04308bbdc67beb) | fix | update `minimist` to `1.2.6` | + +## Special Thanks + +Alan Agius and Doug Parker + + + + + +# 13.3.0 (2022-03-16) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------- | +| [c995ed5e8](https://github.com/angular/angular-cli/commit/c995ed5e8a8e1b20cf376f4c48c5141fd5f4548a) | feat | support TypeScript 4.6 | + +## Special Thanks + +Alan Agius and Doug Parker + + + + + +# 13.2.6 (2022-03-09) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------ | +| [90a5531b1](https://github.com/angular/angular-cli/commit/90a5531b1fbe4043ab47f921ad6b858d34e7c7d0) | fix | ignore css only chunks during naming | + +## Special Thanks + +Alan Agius, Charles Lyding and Daniele Maltese + + + + + +# 13.2.5 (2022-02-23) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------- | +| [acf1e5e4a](https://github.com/angular/angular-cli/commit/acf1e5e4a5b359be125272f7e4055208116a13d8) | fix | don't rename blocks which have a name | +| [7a493979c](https://github.com/angular/angular-cli/commit/7a493979ccb71e974d668fca67d75e1b194f8608) | fix | update `terser` to `5.11.0` | + +## Special Thanks + +Alan Agius and Paul Gschwendtner + + + + + +# 13.2.4 (2022-02-17) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------- | +| [48c655ac9](https://github.com/angular/angular-cli/commit/48c655ac98e1d69622dd832c6a915c48e703cd8f) | fix | update `esbuild` to `0.14.22` | +| [c0736ea0b](https://github.com/angular/angular-cli/commit/c0736ea0b173861bb5ceb9315d27038eb28535e1) | fix | update license-webpack-plugin to 4.0.2 | + +## Special Thanks + +Alan Agius, Anner Visser and Charles Lyding + + + + + +# 13.2.3 (2022-02-09) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------- | +| [8c8377fee](https://github.com/angular/angular-cli/commit/8c8377fee4999266f4e58bf3c3091100d4393df7) | fix | block Karma from starting until build is complete | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [1317e470e](https://github.com/angular/angular-cli/commit/1317e470ec74d1dd9dced2d0ec0022abfe921995) | fix | support locating PNPM lock file during NGCC processing | + +## Special Thanks + +Alan Agius, Derek Cormier and Joey Perrott + + + + + +# 13.2.2 (2022-02-02) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [cc5505cfc](https://github.com/angular/angular-cli/commit/cc5505cfcf12732fad4f85e6e76c8e4f0584c13a) | fix | add `whatwg-url` to downlevel exclusion list | +| [ff54b49e7](https://github.com/angular/angular-cli/commit/ff54b49e7097cda2eb835bc8c9674f71fcc91c3c) | fix | ensure to use content hash as filenames hashing mechanism | +| [b0e2bb289](https://github.com/angular/angular-cli/commit/b0e2bb289050efc77478a0f50778abbec9c5a318) | perf | update `license-webpack-plugin` to `4.0.1` | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [c8826a973](https://github.com/angular/angular-cli/commit/c8826a9738f860e374bd65a058c6be1b02545133) | fix | correctly resolve schema references defaults | + +## Special Thanks + +Alan Agius, Derek Cormier and Joey Perrott + + + + + +# 13.2.1 (2022-01-31) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------- | +| [acd752773](https://github.com/angular/angular-cli/commit/acd752773d85e4debbc2b415c7ea369bc3d7018a) | fix | invalid browsers version ranges | + +## Special Thanks + +Alan Agius + + + + + +# 13.2.0 (2022-01-26) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [41a828e20](https://github.com/angular/angular-cli/commit/41a828e2068b881f744846c3f0edbff8c62cb9ce) | fix | updated Angular new project version to v13.2.0-next.0 | + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------- | +| [f2c6b2b7e](https://github.com/angular/angular-cli/commit/f2c6b2b7ec88a1b7e45884b38faa0978af1b4b74) | fix | correctly handle ESM builders | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------------------- | +| [cbe028e37](https://github.com/angular/angular-cli/commit/cbe028e37c8af6f2e17cbbeddc968c9410151bbb) | feat | expose i18nDuplicateTranslation option of browser and server builders | +| [509322b62](https://github.com/angular/angular-cli/commit/509322b6214b3425bd209087ac99ee9b14edeaba) | fix | Don't use TAILWIND_MODE=watch | + +### @angular-devkit/build-webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------- | +| [820ff2a3e](https://github.com/angular/angular-cli/commit/820ff2a3e84c5a55e23359e3a45714db83362a2a) | fix | correctly handle ESM webpack configurations | + +## Special Thanks + +Alan Agius, Cédric Exbrayat, Derek Cormier, Doug Parker, Joey Perrott, Jordan Pittman, grant-wilson and minijus + + + + + +# 13.1.4 (2022-01-19) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [2f2069dba](https://github.com/angular/angular-cli/commit/2f2069dbaa70c3d4725923f1c3ccbf56b1f57576) | fix | disable parsing `new URL` syntax | +| [bddd0fb9f](https://github.com/angular/angular-cli/commit/bddd0fb9f34a8706dd1646952eed08970b9cddbe) | fix | support ESNext as target for JavaScript optimizations | + +## Special Thanks + +Alan Agius, Derek Cormier and Doug Parker + + + + + +# 13.1.3 (2022-01-12) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------- | +| [4c9d72c65](https://github.com/angular/angular-cli/commit/4c9d72c659d912bd9ef4590a2e88340932a96868) | fix | remove extra space in `Unable to find compatible package` during `ng add` | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------- | +| [9b07191b1](https://github.com/angular/angular-cli/commit/9b07191b1ccdcd2a6bb17686471acddd5862dcf5) | fix | set `skipTest` flag for resolvers when using ng new --skip-tests | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [5b39e0eca](https://github.com/angular/angular-cli/commit/5b39e0eca6e8a3825f66ad6cd1818e551bf98f08) | fix | automatically purge stale build cache entries | +| [6046e06b9](https://github.com/angular/angular-cli/commit/6046e06b926af29f89c605504f5356ec553c6390) | fix | correctly resolve `core-js/proposals/reflect-metadata` | +| [de68daa55](https://github.com/angular/angular-cli/commit/de68daa5581dd1f257382da16704d442b540ec41) | fix | enable `:where` CSS pseudo-class | +| [6a617ff4a](https://github.com/angular/angular-cli/commit/6a617ff4a2fe75968965dc5dcf0f3ba7bae92935) | fix | ensure `$localize` calls are replaced in watch mode | +| [92b4e067b](https://github.com/angular/angular-cli/commit/92b4e067b24bdcd1bb7e40612b5355ce61e040ce) | fix | load translations fresh start | +| [d674dcd1a](https://github.com/angular/angular-cli/commit/d674dcd1af409910dd4f41ac676349aee363ebdb) | fix | localized bundle generation fails in watch mode | +| [6876ad36e](https://github.com/angular/angular-cli/commit/6876ad36efaadac5c4d371cff96c9a4cfa0e3d2b) | fix | use `contenthash` instead of `chunkhash` for chunks | +| [11fd02105](https://github.com/angular/angular-cli/commit/11fd02105908e155c4a9c7f87e9641127cc2f378) | fix | websocket client only injected if required | +| [6ca0e41a9](https://github.com/angular/angular-cli/commit/6ca0e41a9b54aef0a8ea626be73e06d19370f3a7) | perf | update `esbuild` to `0.14.11` | + +## Special Thanks + +Alan Agius, Bill Barry, Derek Cormier, Elio Goettelmann, Joey Perrott, Kasper Christensen, Lukas Spirig and Zoltan Lehoczky + + + + + +# 12.2.15 (2022-01-12) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [526115fdb](https://github.com/angular/angular-cli/commit/526115fdb7d35ff01f5dbdb6027d9f5e925e4056) | fix | updated webpack-dev-server to latest security patch | + +## Special Thanks + +Doug Parker and iRealNirmal + + + +# 11.2.18 (2022-01-12) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------- | +| [534678450](https://github.com/angular/angular-cli/commit/534678450196a45610e88a85ee01317aa43dc788) | fix | updated webpack-dev-server to latest security patch | + +## Special Thanks + +Doug Parker and iRealNirmal + + + +# 13.2.0-next.1 (2021-12-15) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [41a828e20](https://github.com/angular/angular-cli/commit/41a828e2068b881f744846c3f0edbff8c62cb9ce) | fix | updated Angular new project version to v13.2.0-next.0 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [0323a35b4](https://github.com/angular/angular-cli/commit/0323a35b47a4a2fd3870b09d46e3655714e50abd) | fix | add `tailwindcss` support for version 3 | +| [471930007](https://github.com/angular/angular-cli/commit/471930007cb9cd26264eab483fdfd1f5b4db6641) | fix | display FS cache information when `verbose` option is used | +| [f1d2873ca](https://github.com/angular/angular-cli/commit/f1d2873ca7ee337748366d04878514c2c27a72a2) | fix | only extract CSS styles when are specified in `styles` option | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [b03b9eefe](https://github.com/angular/angular-cli/commit/b03b9eefeac77b93931803de208118e3a6c5a928) | perf | reduce redundant module rebuilds when cache is restored | + +## Special Thanks + +Alan Agius, Cédric Exbrayat, Derek Cormier and Doug Parker + + + + + +# 13.1.2 (2021-12-15) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [1ddbd75ae](https://github.com/angular/angular-cli/commit/1ddbd75ae200c14b5f33556bd6d5ae6b7722d14e) | fix | add `tailwindcss` support for version 3 | +| [adf925c07](https://github.com/angular/angular-cli/commit/adf925c0755b6e78a57932becdb7b7a764afb9e6) | fix | display FS cache information when `verbose` option is used | +| [09c3826c9](https://github.com/angular/angular-cli/commit/09c3826c9d9128a6b520d0fe8da3cb466d18cddc) | fix | only extract CSS styles when are specified in `styles` option | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------- | +| [f31d7f79d](https://github.com/angular/angular-cli/commit/f31d7f79dfa8f997fecdcfec1ebc6cfbe657f3fb) | perf | reduce redundant module rebuilds when cache is restored | + +## Special Thanks + +Alan Agius, Derek Cormier and Doug Parker + + + + + +# 11.2.17 (2021-12-16) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [1efff8f82](https://github.com/angular/angular-cli/commit/1efff8f82df38b7485f8a8dcdd5bfea5a457c6a1) | fix | exclude deprecated packages with removal migration from update | + +## Special Thanks + +Alan Agius and Doug Parker + + + + + +# 11.2.16 (2021-12-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------- | +| [f456b0962](https://github.com/angular/angular-cli/commit/f456b0962b9f339759bc86c092256f68d68d9ecf) | fix | error when updating Angular packages across multi-major migrations | +| [886d2511e](https://github.com/angular/angular-cli/commit/886d2511e292b687acce1ac4c6924f992494d14f) | fix | logic which determines which temp version of the CLI is to be download during `ng update` | +| [776d1210a](https://github.com/angular/angular-cli/commit/776d1210a9e62bf2531d977138f49f93820a8b87) | fix | update `ng update` output for Angular packages | + +## Special Thanks + +Alan Agius and Doug Parker + + + + + +# 10.2.4 (2021-12-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------- | +| [745d77728](https://github.com/angular/angular-cli/commit/745d777288a5ae0e79b4ecdf7b8483f242ba8e66) | fix | error when updating Angular packages across multi-major migrations | +| [460ea21b5](https://github.com/angular/angular-cli/commit/460ea21b5d4b8759a3f7457b885110022dd21dfc) | fix | logic which determines which temp version of the CLI is to be download during `ng update` | +| [03da12899](https://github.com/angular/angular-cli/commit/03da1289996790ae574a49bb46123c74417a97c2) | fix | update `ng update` output for Angular packages | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------ | +| [d6582d489](https://github.com/angular/angular-cli/commit/d6582d48944f7bf169f3902e4c19186a6751f473) | fix | change `karma-jasmine-html-reporter` dependency to use tilde | + +## Special Thanks + +Alan Agius, Charles Lyding, Doug Parker and Joey Perrott + + + + + +# 13.1.1 (2021-12-10) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [a315b968a](https://github.com/angular/angular-cli/commit/a315b968a36e6aae990e52d9a18673fef9b5fda6) | fix | updated Angular new project version to v13.1.0 | + +## Special Thanks + +Alan Agius, Cédric Exbrayat and Derek Cormier + + + + + +# 13.1.0 (2021-12-09) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------ | +| [56f802b7d](https://github.com/angular/angular-cli/commit/56f802b7dd26bfc774b6b00982a1dbbe0bafddd0) | feat | ask to install angular-eslint when running ng lint in new projects | +| [ecd9fb5c7](https://github.com/angular/angular-cli/commit/ecd9fb5c774b6301348c4514da04d58ae8903d06) | feat | provide more detailed error for not found builder | +| [0b6071af3](https://github.com/angular/angular-cli/commit/0b6071af3a51e7d3f38a661bd4e0a3c3e81aff2f) | fix | `ng doc` does open browser on Windows | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------- | +| [d5d9f042f](https://github.com/angular/angular-cli/commit/d5d9f042f2ea42573b7ff4fab90cab85d0c5ec0b) | feat | add VS Code configurations when generating a new workspace | +| [f95cc8281](https://github.com/angular/angular-cli/commit/f95cc8281a64bd9ac19e0fa5d92cb0a6ee8c32ec) | feat | generate new projects using TypeScript 4.5 | +| [21809e14c](https://github.com/angular/angular-cli/commit/21809e14cd5c666c82fdaebc9e601341dfb76d0a) | feat | loosen project name validation | + +### @angular-devkit/schematics-cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------ | +| [339bab06c](https://github.com/angular/angular-cli/commit/339bab06cc25863571acb09cb3e877fed14ca2f9) | feat | generate new projects using TypeScript 4.5 | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------- | +| [bc8563760](https://github.com/angular/angular-cli/commit/bc856376039287cf5fb6135ca5da65a9000f5664) | feat | add estimated transfer size to build output report | +| [bc17cf0cd](https://github.com/angular/angular-cli/commit/bc17cf0cdd02bf50758e510756a26e6e6ca32d14) | feat | colorize file raw sizes based on failing budgets | +| [3c681b68d](https://github.com/angular/angular-cli/commit/3c681b68d7a32f1cfaf3feee6b2e02cc6e0f0568) | feat | set `dir` attribute when using localization | +| [6d0f99a2d](https://github.com/angular/angular-cli/commit/6d0f99a2deef957c15836c172b9f68f716f836a4) | feat | support JSON comments in dev-server proxy configuration file | +| [9300545e6](https://github.com/angular/angular-cli/commit/9300545e6148b4548cc02bb6a311a2f0e2bb79c5) | feat | watch i18n translation files with dev server | +| [9bacba342](https://github.com/angular/angular-cli/commit/9bacba3420cda7897091522415a8d55cf1b75106) | fix | differentiate components and global styles using file query instead of filename | +| [7408511da](https://github.com/angular/angular-cli/commit/7408511da555f37560ca7e3b536e15dfc8f6a1e5) | fix | display cleaner errors | +| [d55fc62ef](https://github.com/angular/angular-cli/commit/d55fc62ef2f8bc7a6f1190f56f8e8b64c9195263) | fix | fallback to use language ID to set the `dir` attribute | +| [4c288b8bd](https://github.com/angular/angular-cli/commit/4c288b8bd28e7215887aa52025c4fa41fcf7bc01) | fix | lazy modules bundle budgets | +| [562dc6a89](https://github.com/angular/angular-cli/commit/562dc6a8924826509d9012b2c0fe61c089077399) | fix | prefer ES2015 entrypoints when application targets ES2019 or lower | +| [ac66e400c](https://github.com/angular/angular-cli/commit/ac66e400cddc81bde46949d1abe4560185dfbedb) | fix | Sass compilation in StackBlitz webcontainers | +| [e1bac5bbb](https://github.com/angular/angular-cli/commit/e1bac5bbb36f391b89445ba61abe561c75746f30) | fix | update Angular peer dependencies to v13.1 prerelease | +| [789ddfaeb](https://github.com/angular/angular-cli/commit/789ddfaeb0fcbc9aab1581384b88c3618e606c4b) | perf | disable webpack backwards compatible APIs | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [5402f99f8](https://github.com/angular/angular-cli/commit/5402f99f8ad20e0a57456a416a992415fc6332bd) | fix | add `cjs` and `mjs` to passthrough files | +| [10d4ede2d](https://github.com/angular/angular-cli/commit/10d4ede2de42dfc302dcb4c5790274290170568d) | fix | handle promise rejection during Angular program analyzes | + +## Special Thanks + +Alan Agius, Charles Lyding, Doug Parker, Ferdinand Malcher, Joey Perrott and Ruslan Lekhman + + + + + +# 12.2.14 (2021-12-07) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------- | +| [30295b33e](https://github.com/angular/angular-cli/commit/30295b33ed74667f31e9d3a4a0017910a85fd734) | fix | error when updating Angular packages across multi-major migrations | +| [e07bd059e](https://github.com/angular/angular-cli/commit/e07bd059e3d6bc6b40191c036c467595ed119da7) | fix | logic which determines which temp version of the CLI is to be download during `ng update` | +| [ce1ec0420](https://github.com/angular/angular-cli/commit/ce1ec0420770a8e28c1c1301df9e5eb4548d4c53) | fix | update `ng update` output for Angular packages | +| [dd9f8df52](https://github.com/angular/angular-cli/commit/dd9f8df5204d639272f183795ebd48d7994df427) | fix | update `pacote` to `12.0.2` | + +## Special Thanks + +Alan Agius and Doug Parker + + + + + +# 13.0.4 (2021-12-01) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------- | +| [ded7b5c06](https://github.com/angular/angular-cli/commit/ded7b5c069a145d1b3e264538d7c4302919ad030) | fix | exit with a non-zero error code when migration fails during `ng update` | +| [250a58b48](https://github.com/angular/angular-cli/commit/250a58b4820a738aba7609627fa7fce0a24f10db) | fix | logic which determines which temp version of the CLI is to be download during `ng update` | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------- | +| [372e2e633](https://github.com/angular/angular-cli/commit/372e2e633f4bd9bf29c35d02890e1c6a70da3169) | fix | address eslint linting failures in `test.ts` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------- | +| [b835389c8](https://github.com/angular/angular-cli/commit/b835389c8a60749151039ed0baf0be025ce0932b) | fix | correctly extract messages when using cached build ([#22266](https://github.com/angular/angular-cli/pull/22266)) | +| [647a5f0b1](https://github.com/angular/angular-cli/commit/647a5f0b18e49b2ece3f43c0a06bfb75d7caef49) | fix | don't watch nested `node_modules` when polling is enabled | +| [4d01d4f72](https://github.com/angular/angular-cli/commit/4d01d4f72344c42f650f5495b21e6bd94069969a) | fix | transform remapped sourcemap into a plain object | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------------------- | +| [4d918ef99](https://github.com/angular/angular-cli/commit/4d918ef9912d53a09d73fb19fa41b121dceed37c) | fix | JIT mode CommonJS accessing inexistent `default` property | + +## Special Thanks + +Alan Agius, Billy Lando, David-Emmanuel DIVERNOIS and Derek Cormier + + + + + +# 13.0.3 (2021-11-17) + +## Special Thanks + +Alan Agius, Joey Perrott and Krzysztof Platis + + + + + +# 13.0.2 (2021-11-10) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------ | +| [34047b1ad](https://github.com/angular/angular-cli/commit/34047b1adccd7eb852c1900c872e9ca71c8d4cd9) | fix | avoid redirecting @angular/core in Angular migrations | +| [ff4538e98](https://github.com/angular/angular-cli/commit/ff4538e981cfff49b6e8433ffcb5ac2d2ea5d07e) | fix | favor ng-update `packageGroupName` in ng update output | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [1bc00b6fe](https://github.com/angular/angular-cli/commit/1bc00b6feb9033fd611dec965c82f03e4135a9f4) | fix | migrate ng-packagr configurations in package.json | +| [9ea74a13d](https://github.com/angular/angular-cli/commit/9ea74a13d07208373490c7cdb3ff7c452c698322) | fix | show warning when migrating ng-packagr JS configurations | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------- | +| [35164bf92](https://github.com/angular/angular-cli/commit/35164bf92b986a67215580622aaddc4148a7c822) | fix | don't restore `input` of type `file` during HMR | +| [facb5d8ff](https://github.com/angular/angular-cli/commit/facb5d8ffd4f6a81d3132515b8bae64278cf8316) | fix | don't show `[NG HMR] Unknown input type` when restoring file type input | +| [ef8815d04](https://github.com/angular/angular-cli/commit/ef8815d0434836f2d8119e91a7bc09742ff77d37) | fix | improve sourcemap fidelity during code-coverage | +| [966a1334a](https://github.com/angular/angular-cli/commit/966a1334a6502f5d4a18710ae22e739e62770101) | fix | suppress "@charset" must be the first rule in the file warning | +| [1cdc24da0](https://github.com/angular/angular-cli/commit/1cdc24da0105fad75221e3c145de12dafc601059) | fix | update Angular peer dependencies to 13.0 stable | + +## Special Thanks + +Alan Agius, Charles Lyding, Joey Perrott and Paul Gschwendtner + + + + + +# 13.0.1 (2021-11-03) + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------- | +| [40f599241](https://github.com/angular/angular-cli/commit/40f599241e278478c694580c9dec4f5cc34db011) | fix | updated Angular new project version to v13.0.0 | + +## Special Thanks + +Charles Lyding and Joey Perrott + + + + + +# 12.2.13 (2021-11-03) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [a2bd940e4](https://github.com/angular/angular-cli/commit/a2bd940e4ab44db57b0fc69d5346d2862a19c879) | fix | add verbose logging for differential loading and i18n | + +## Special Thanks + +Charles Lyding and Doug Parker + + + + + +# 13.0.0 (2021-11-03) + +## Breaking Changes + +### @angular/cli + +- We drop support for Node.js versions prior to `12.20`. + +### @schematics/angular + +- `classlist.js` and `web-animations-js` are removed from application polyfills and uninstalled from the package. These were only needed for compatibility with Internet Explorer, which is no longer needed now that Angular only supports evergreen browsers. See: https://angular.dev/reference/versions#browser-support. + +Add the following to the polyfills file for an app to re-add these packages: + +```typescript +import 'classlist.js'; +import 'web-animations-js'; +``` + +And then run: + +```sh +npm install classlist.js web-animations-js --save +``` + +- We removed several deprecated `@schematics/angular` deprecated options. +- `lintFix` have been removed from all schematics. `ng lint --fix` should be used instead. +- `legacyBrowsers` have been removed from the `application` schematics since IE 11 is no longer supported. +- `configuration` has been removed from the `web-worker` as it was unused. +- `target` has been removed from the `service-worker` as it was unused. + +### @angular-devkit/build-angular + +- Support for `karma-coverage-instanbul-reporter` has been dropped in favor of the official karma coverage plugin `karma-coverage`. + +- Support for `node-sass` has been removed. `sass` will be used by default to compile SASS and SCSS files. + +- `NG_PERSISTENT_BUILD_CACHE` environment variable option no longer have effect. Configure `cli.cache` in the workspace configuration instead. + +```json +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "cache": { + "enabled": true, + "path": ".custom-cache-path", + "environment": "all" + } + } + ... +} +``` + +- Calling `BuilderContext.scheduleBuilder()` with a builder from `@angular-devkit/build-angular` now requires passing the `target` property in the 3rd argument, like in the following example: + + ```typescript + context.scheduleBuilder('@angular-devkit/build-angular:ng-packagr', options, { + target: context.target, + }); + ``` + +- The automatic inclusion of Angular-required ES2015 polyfills to support ES5 browsers has been removed. Previously when targeting ES5 within the application's TypeScript configuration or listing an ES5 requiring browser in the browserslist file, Angular-required polyfills were included in the built application. However, with Angular no longer supporting IE11, there are now no browsers officially supported by Angular that would require these polyfills. As a result, the automatic inclusion of these ES2015 polyfills has been removed. Any polyfills manually added to an application's code are not affected by this change. + +- With this change a number of deprecated dev-server builder options which proxied to the browser builder have been removed. These options should be configured in the browser builder instead. + +The removed options are: + +- `aot` +- `sourceMap` +- `deployUrl` +- `baseHref` +- `vendorChunk` +- `commonChunk` +- `optimization` +- `progress` + +- With this change we removed several deprecated builder options +- `extractCss` has been removed from the browser builder. CSS is now always extracted. +- `servePathDefaultWarning` and `hmrWarning` have been removed from the dev-server builder. These options had no effect. + +- Deprecated `@angular-devkit/build-angular:tslint` builder has been removed. Use https://github.com/angular-eslint/angular-eslint instead. + +- Differential loading support has been removed. With Angular no longer supporting IE11, there are now no browsers officially supported by Angular that require ES5 code. As a result, differential loading's functionality for creating and conditionally loading ES5 and ES2015+ variants of an application is no longer required. + +- TypeScript versions prior to 4.4 are no longer supported. + +- The dev-server now uses WebSockets to communicate changes to the browser during HMR and live-reloaded. If during your development you are using a proxy you will need to enable proxying of WebSockets. + +- We remove inlining of Google fonts in WOFF format since IE 11 is no longer supported. Other supported browsers use WOFF2. + +### @angular-devkit/build-webpack + +- Support for `webpack-dev-server` version 3 has been removed. For more information about the migration please see: https://github.com/webpack/webpack-dev-server/blob/master/migration-v4.md + +Note: this change only affects users depending on `@angular-devkit/build-webpack` directly. + +### @angular-devkit/core + +- With this change we drop support for the deprecated behaviour to transform `id` in schemas. Use `$id` instead. + +Note: this only effects schematics and builders authors. + +- The deprecated JSON parser has been removed from public API. [jsonc-parser](https://www.npmjs.com/package/jsonc-parser) should be used instead. + +### @angular-devkit/schematics + +- `isAction` has been removed without replacement as it was unused. + +- With this change we remove the following deprecated APIs +- `TslintFixTask` +- `TslintFixTaskOptions` + +**Note:** this only effects schematics developers. + +### @ngtools/webpack + +- Deprecated `inlineStyleMimeType` option has been removed from `AngularWebpackPluginOptions`. Use `inlineStyleFileExtension` instead. + +- Applications directly using the `webpack-cli` and not the Angular CLI to build must set the environment variable `DISABLE_V8_COMPILE_CACHE=1`. The `@ngtools/webpack` package now uses dynamic imports to provide support for the ESM `@angular/compiler-cli` package. The `v8-compile-cache` package used by the `webpack-cli` does not currently support dynamic import expressions and will cause builds to fail if the environment variable is not specified. Applications using the Angular CLI are not affected by this limitation. + +## Deprecations + +### + +- `@angular-devkit/build-optimizer` + +It's functionality has been included in `@angular-devkit/build-angular` so this package is no longer needed by the CLI and we will stop publishing the package soon. It has been an experimental (never hit `1.0.0`) and internal (only used by Angular itself) package and should be not be used directly by others. + +### @angular-devkit/build-angular + +- `NG_BUILD_CACHE` environment variable option will be removed in the next major version. Configure `cli.cache` in the workspace configuration instead. + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------- | +| [9fe55752d](https://github.com/angular/angular-cli/commit/9fe55752db8bb50cad5a1ddfe670dce06528e23e) | feat | officially support Node.js v16 | +| [5ad145722](https://github.com/angular/angular-cli/commit/5ad145722f66af526a36983b259c6d625c93f307) | fix | error when updating Angular packages across multi-major migrations | +| [e4bc35e33](https://github.com/angular/angular-cli/commit/e4bc35e332e378f8d238f4069dc56f422fe205d6) | fix | exclude packages from ng add that contain invalid peer dependencies | +| [e1b954d70](https://github.com/angular/angular-cli/commit/e1b954d707f90622d8a75fc45840cefeb224c286) | fix | keep relative migration paths during update analysis | +| [c3acf3cc2](https://github.com/angular/angular-cli/commit/c3acf3cc26b9e37a3b8f4c369f42731f46b522ee) | fix | remove unused cli project options. | +| [77fe6c4e6](https://github.com/angular/angular-cli/commit/77fe6c4e67147ff42fa6350edaf4ef7dc184a3a6) | fix | update `engines` to require `node` `12.20.0` | +| [8795536a3](https://github.com/angular/angular-cli/commit/8795536a31efbed6373787188cb21c5d1e0accbd) | fix | update `ng update` output for Angular packages | +| [d8c9f6eaf](https://github.com/angular/angular-cli/commit/d8c9f6eaf4513639741d20c6af97a751b33b968e) | fix | update the update command to fully support Node.js v16 | + +### @schematics/angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------- | +| [7ff8c5350](https://github.com/angular/angular-cli/commit/7ff8c5350ea2e49574dd659adae02215957d2685) | feat | add `/.angular/cache` to `.gitignore` | +| [3ba13f467](https://github.com/angular/angular-cli/commit/3ba13f467c12f4ad0c314cc92a2d94fb63f640ec) | feat | add `noImplicitOverride` and `noPropertyAccessFromIndexSignature` to workspace tsconfig | +| [268a03b63](https://github.com/angular/angular-cli/commit/268a03b63094d9c680401bc0977edafb22826ce3) | feat | add migration to update the workspace config | +| [7bdcd7da1](https://github.com/angular/angular-cli/commit/7bdcd7da1ff3a31f4958d90d856beb297e99b187) | feat | create new projects with rxjs 7 | +| [eac18aed7](https://github.com/angular/angular-cli/commit/eac18aed78da55efb840a3ef6f5e90718946504c) | feat | drop polyfills required only for Internet Explorer now that support has been dropped for it | +| [4f91816b2](https://github.com/angular/angular-cli/commit/4f91816b2951c0e2b0109ad1938eb0ae632c0c76) | feat | migrate libraries to be published from ViewEngine to Ivy Partial compilation | +| [5986befcd](https://github.com/angular/angular-cli/commit/5986befcdc953c0e8c90c756ac1c89b8c4b66614) | feat | remove deprecated options | +| [9fbd16655](https://github.com/angular/angular-cli/commit/9fbd16655e86ec6fc598a47436e3e80a48beb649) | feat | remove IE 11 specific polyfills | +| [a7b2e6f51](https://github.com/angular/angular-cli/commit/a7b2e6f512d2a1124f0d2c68caacfe6552a10cd5) | feat | update ngsw-config resources extensions | +| [732ef7985](https://github.com/angular/angular-cli/commit/732ef798523f74994ed3d482a65b191058674d19) | fix | add browserslist configuration in library projects | +| [585adacd0](https://github.com/angular/angular-cli/commit/585adacd0624ddf32c5c69a755d8e542f3463861) | fix | don't add `destroyAfterEach` in newly generated spec files | +| [e58226ee9](https://github.com/angular/angular-cli/commit/e58226ee948ea88f27a81d50d71945b5c9c39ee3) | fix | don't export `renderModuleFactory` from server file | +| [0ec0ad8a4](https://github.com/angular/angular-cli/commit/0ec0ad8a4dba4a778b368c5cd76ef13fb370b310) | fix | remove `target` and `lib` options for library tsconfig | +| [f227e145d](https://github.com/angular/angular-cli/commit/f227e145dfbec2954cb96c92ab3c4cb97cbe0f32) | fix | updated Angular new project version to v13.0 prerelease | + +### + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- | +| [5e435ff37](https://github.com/angular/angular-cli/commit/5e435ff37703f9ffea7fa92fbd5cd42d9a3db07e) | docs | mark `@angular-devkit/build-optimizer` as deprecated. | + +### @angular-devkit/architect + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ | +| [09e039500](https://github.com/angular/angular-cli/commit/09e039500f34b0d6a16e62128409ac5821e8b9c2) | feat | include workspace extensions in project metadata | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------- | +| [f53bf9dc2](https://github.com/angular/angular-cli/commit/f53bf9dc21ee9aa8a682b8a82ee8a9870fa859e1) | feat | add `type=module` to all scripts tags | +| [e95ecb8ab](https://github.com/angular/angular-cli/commit/e95ecb8ab0382eb803741619c446d6cc7b215ba0) | feat | deprecate deployUrl | +| [7dcfffaff](https://github.com/angular/angular-cli/commit/7dcfffafff6f3d29bbe679a90cdf77b1292fec0b) | feat | drop support for `karma-coverage-instanbul-reporter` | +| [ac3fc2752](https://github.com/angular/angular-cli/commit/ac3fc2752f28761e1cd42157b59dcf2364ae5567) | feat | drop support for `node-sass` | +| [5904afd1d](https://github.com/angular/angular-cli/commit/5904afd1de3ffa0bb6cd1757795ba9abfce9e523) | feat | enable disk cache by default and provide configurable options | +| [22cd9edfa](https://github.com/angular/angular-cli/commit/22cd9edfafd357bb9d62a93dd56f033b3f34bbe8) | feat | favor es2020 main fields | +| [7576136b2](https://github.com/angular/angular-cli/commit/7576136b2fc8a9173b0a92e2ab14c9bc2559081e) | feat | remove automatic inclusion of ES5 browser polyfills | +| [000b0e51c](https://github.com/angular/angular-cli/commit/000b0e51c166ecd26b6f24d6a133ea5076df9849) | feat | remove deprecated dev-server options | +| [20e48a33c](https://github.com/angular/angular-cli/commit/20e48a33c14a1b0b959ba0a45018df53a3e129c8) | feat | remove deprecated options | +| [e78f6ab5d](https://github.com/angular/angular-cli/commit/e78f6ab5d8f00338d826c8407ce5c8fca40cf097) | feat | remove deprecated tslint builder | +| [701214d17](https://github.com/angular/angular-cli/commit/701214d174586fe7373b6155024c9b6e97b26377) | feat | remove differential loading support | +| [fb1ad7c5b](https://github.com/angular/angular-cli/commit/fb1ad7c5b3fa3df85f1d3dff3850e1ad0003ef9d) | feat | support ESM proxy configuration files for the dev server | +| [505438cc4](https://github.com/angular/angular-cli/commit/505438cc4146b1950038531ce30e1f62f7c41d00) | feat | support TypeScript 4.4 | +| [32dbf659a](https://github.com/angular/angular-cli/commit/32dbf659acb632fac1d76d99d8191ea9c5e6350b) | feat | update `webpack-dev-server` to version 4 | +| [c1efaa17f](https://github.com/angular/angular-cli/commit/c1efaa17feb1d2911dcdea12688d75086d410bf1) | fix | calculate valid Angular versions from peerDependencies | +| [d7af4a7b5](https://github.com/angular/angular-cli/commit/d7af4a7b536a7c43704f808ea208bc9f230d2403) | fix | enable custom `es2020` and `es2015` conditional exports | +| [f383f3201](https://github.com/angular/angular-cli/commit/f383f3201b69d28f8755c0bd63134619f9da408d) | fix | ESM-interop loaded plugin creators of `@angular/localize/tools` not respected | +| [7934becb5](https://github.com/angular/angular-cli/commit/7934becb581d07c8e1f74898ddd4c20f050be659) | fix | generate unique webpack runtimes | +| [b14e0a547](https://github.com/angular/angular-cli/commit/b14e0a54727352a6939c7a0ff13dffe2deaa67d2) | fix | improve sourcemaps fidelity when code coverage is enabled | +| [e19287453](https://github.com/angular/angular-cli/commit/e19287453c10740ea21b31a6c8a3cd5f3714955d) | fix | move `@angular/localize` detection prior to webpack initialization | +| [76d6d8826](https://github.com/angular/angular-cli/commit/76d6d8826f9968f84edf219f67b84673d70bbe95) | fix | set browserslist defaults | +| [167eed465](https://github.com/angular/angular-cli/commit/167eed4654be4480c45d7fdfe7a0b9f160170289) | fix | update Angular peer dependencies to v13.0 prerelease | +| [1d8cdf853](https://github.com/angular/angular-cli/commit/1d8cdf853dc8fdea78b067a715b3342ed9427caa) | fix | update esbuild to 0.13.12 | +| [884111ac0](https://github.com/angular/angular-cli/commit/884111ac0b8a73dca06d844b2ed795a3e3ed3289) | fix | update IE unsupported and deprecation messages | +| [4be6537dd](https://github.com/angular/angular-cli/commit/4be6537ddf4b32e8d204dbaa75f1a53712fe9d44) | fix | update TS/JS regexp checks to latest extensions | +| [427a9ee97](https://github.com/angular/angular-cli/commit/427a9ee9738c0911caeaba5fb4b59d183ffe6244) | fix | update workspace tsconfig lib es2020 | +| [ea926db25](https://github.com/angular/angular-cli/commit/ea926db257ad3b042af86178e472b5763a695146) | fix | use es2015 when generating server bundles | +| [13cceab8e](https://github.com/angular/angular-cli/commit/13cceab8e737a12d0809f184f852ceb5620d81fb) | fix | use URLs for absolute import paths with ESM | +| [4e0743c8a](https://github.com/angular/angular-cli/commit/4e0743c8ad5879f212f2ea232ac9492848a8df2c) | perf | change webpack hashing function to `xxhash64` | +| [cb7d156c2](https://github.com/angular/angular-cli/commit/cb7d156c23a7ef2f1c2f338db1487b85f8b98690) | perf | use esbuild as a CSS optimizer for global styles | +| [8e82263c5](https://github.com/angular/angular-cli/commit/8e82263c5e7da6ca25bdd4e2ce9ad2c775d623b7) | perf | use esbuild/terser combination to optimize global scripts | +| [e82eef924](https://github.com/angular/angular-cli/commit/e82eef924eb172a98fa157a958bde2cfcaa52ce6) | refactor | remove WOFF handling from inline-fonts processor | + +### @angular-devkit/build-webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------- | +| [a0b5897d5](https://github.com/angular/angular-cli/commit/a0b5897d50a00ee4668029c2cbc47cacd2ab925f) | feat | update `webpack-dev-server` to version 4 | +| [9efcb32e3](https://github.com/angular/angular-cli/commit/9efcb32e378442714eae4caec43281123c5e30f6) | fix | better handle concurrent dev-servers | + +### @angular-devkit/core + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------- | +| [0c92ea5ca](https://github.com/angular/angular-cli/commit/0c92ea5ca34d82849862d55c4210cf62c819d514) | feat | remove deprecated schema id handling | +| [9874aff71](https://github.com/angular/angular-cli/commit/9874aff71ecb5f3baf6c1dcc489581d1dcb58491) | fix | add missing option peer dependency on `chokidar` | +| [a54e5e065](https://github.com/angular/angular-cli/commit/a54e5e06551c828eb5cf08695674e04fd8a78bf3) | fix | support Node.js v16 with `NodeJsSyncHost`/`NodeJsAsyncHost` delete operation | +| [d722fdf1f](https://github.com/angular/angular-cli/commit/d722fdf1f67c394762906794605bc1ad657670d1) | refactor | remove deprecated JSON parser | + +### @angular-devkit/schematics + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [0565ed62e](https://github.com/angular/angular-cli/commit/0565ed62eb08c1e82cffb2533e6afde216c37eb7) | feat | add UpdateBuffer2 based on magic-string | +| [8954d1152](https://github.com/angular/angular-cli/commit/8954d1152b6c1a33dd7d4b63d2fa430d91e7b370) | feat | remove deprecated `isAction` | +| [053b7d66c](https://github.com/angular/angular-cli/commit/053b7d66c269423804891e4d43d61f8605838e24) | feat | remove deprecated tslint APIs | +| [bdd89ae84](https://github.com/angular/angular-cli/commit/bdd89ae84ad6919b670dde862de72f562c86d0c5) | fix | handle zero or negative length removals in update buffer | + +### @ngtools/webpack + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------- | +| [d2a97f919](https://github.com/angular/angular-cli/commit/d2a97f9193fcf7e454fe8eb48c0ed732d3b2f24f) | fix | update Angular peer dependencies to v13.0 prerelease | +| [7928b18ed](https://github.com/angular/angular-cli/commit/7928b18edf34243a404b5a4f40a5d6e40247d797) | perf | reduce repeat path mapping analysis during resolution | +| [8ce8e4edc](https://github.com/angular/angular-cli/commit/8ce8e4edc5ca2984d6a36fe4c7d308fa7f089102) | refactor | remove deprecated `inlineStyleMimeType` option | +| [7d98ab3df](https://github.com/angular/angular-cli/commit/7d98ab3df9f7c15612c69cedca5a01a535301508) | refactor | support an ESM-only `@angular/compiler-cli` package | + +## Special Thanks + +Alan Agius, Charles Lyding, Doug Parker, Douglas Parker, Joey Perrott, Kristiyan Kostadinov, Lukas Spirig and Paul Gschwendtner + + + + + +# 12.2.9 (2021-10-06) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------- | +| [9d45b7752](https://github.com/angular/angular-cli/commit/9d45b77522a9693c4876fdfd741e8869e89e0268) | fix | add web-streams-polyfill to downlevel exclusion list | +| [ccedf53a8](https://github.com/angular/angular-cli/commit/ccedf53a820a748b56c84528294b36c7af30dbaf) | fix | update `esbuild` to `0.13.4` | + +## Special Thanks + +Alan Agius and Charles Lyding + + + + + +# 12.2.8 (2021-10-01) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------- | +| [821a1b5a9](https://github.com/angular/angular-cli/commit/821a1b5a949d53f2e82f734062b711a166d42e24) | fix | babel adjust enum plugin incorrectly transforming loose enums | + +## Special Thanks + +Paul Gschwendtner + + + + + +# 12.2.7 (2021-09-22) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | --------------------------------------------- | +| [d856b4d23](https://github.com/angular/angular-cli/commit/d856b4d2369bea76ce65fc5f6d1585145ad41618) | fix | support WASM-based esbuild optimizer fallback | + +## Special Thanks + +Alan Agius and Charles Lyding + + + + + +# 12.2.6 (2021-09-15) + +### @angular/cli + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [8b21effad](https://github.com/angular/angular-cli/commit/8b21effad673877cf1a82ef7d0601393a65517fb) | fix | handle `FORCE_COLOR` when stdout is not instance of `WriteStream` | + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------- | +| [ea60f0f52](https://github.com/angular/angular-cli/commit/ea60f0f527f2ab8fc5acc967138c4ae993946923) | fix | handle `FORCE_COLOR` when stdout is not instance of `WriteStream` | + +## Special Thanks + +Alan Agius + + + + + +# 12.2.5 (2021-09-08) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------- | +| [0498768c5](https://github.com/angular/angular-cli/commit/0498768c54de225a40c28fdf27bb1fc43959ba20) | fix | disable dev-server response compression | +| [367fce2e9](https://github.com/angular/angular-cli/commit/367fce2e9f9389c41f2ed5361ef6749198c49785) | fix | improve Safari browserslist to esbuild target conversion | + +## Special Thanks: + +Alan Agius and Charles Lyding + + + + + +# 12.2.4 (2021-09-01) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------- | +| [aaadef026](https://github.com/angular/angular-cli/commit/aaadef02698ba729ca04ccd4159bda5b6582babb) | fix | update `esbuild` to `0.12.24` | +| [f8a9f4a01](https://github.com/angular/angular-cli/commit/f8a9f4a0100286b7cf656ffbe486c3424cad5172) | fix | update `mini-css-extract-plugin` to `2.2.1` | + +## Special Thanks + +Alan Agius + + + + + +# 12.2.3 (2021-08-26) + +### @angular-devkit/build-angular + +| Commit | Type | Description | +| --------------------------------------------------------------------------------------------------- | ---- | -------------------------------------------------------------- | +| [3e3321857](https://github.com/angular/angular-cli/commit/3e33218578007f93a131dc8be569e9985179098f) | fix | RGBA converted to hex notation in component styles breaks IE11 | + +## Special Thanks: + +Alan Agius and Trevor Karjanis + + + + + +# 12.2.2 (2021-08-18) + +### @angular-devkit/build-angular + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| [a55118a75](https://github.com/angular/angular-cli/commit/a55118a753555c0082cfd434379559df7e3eb7f9) | fix: provide supported browsers to esbuild | +| [81baa4f95](https://github.com/angular/angular-cli/commit/81baa4f956443fcc718f9021fd23ab7064d04607) | fix: update Angular peer dependencies to 12.2 stable | +| [297410ae8](https://github.com/angular/angular-cli/commit/297410ae860860d71905639cf38b49ff05813845) | fix: handle undefined entrypoints when marking async chunks | + +### @ngtools/webpack + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| [b7199f366](https://github.com/angular/angular-cli/commit/b7199f366841d976b502ad5f1923e24ea2f6b302) | fix: update Angular peer dependencies to 12.2 stable | + +## Special Thanks: + +Alan Agius, Charles Lyding, Joey Perrott and Simon Primetzhofer + + + + + +# 12.2.1 (2021-08-11) + +### @angular/cli + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| [8dc3c895a](https://github.com/angular/angular-cli/commit/8dc3c895a6531316e672031c8d0815781f0c089a) | fix(@angular/cli): show error when using non-TTY terminal without passing `--skip-confirmation` during `ng add` | + +### @angular-devkit/schematics-cli + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| [eded01270](https://github.com/angular/angular-cli/commit/eded01270f9aa70f6ba4806a068de8d1c0a52454) | fix(@angular-devkit/schematics-cli): log when in debug and/or dry run modes | + +### @angular-devkit/build-angular + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| [22e0208a9](https://github.com/angular/angular-cli/commit/22e0208a9ee6257213b3bf93ac61a2c3d4ac9504) | fix(@angular-devkit/build-angular): ensure native async is downlevelled in third-party libraries | +| [9b4b86fb0](https://github.com/angular/angular-cli/commit/9b4b86fb0d9c88a3c714f5eabf925859bb7b71bb) | fix(@angular-devkit/build-angular): support both pure annotation forms for static properties | +| [cea028090](https://github.com/angular/angular-cli/commit/cea0280908db39308ac5fa37374b138ceb79ecea) | fix(@angular-devkit/build-angular): do not consume inline sourcemaps when vendor sourcemaps is disabled. | +| [e7ec0346e](https://github.com/angular/angular-cli/commit/e7ec0346e69c090ded7d9ec6d3574deb79926db0) | fix(@angular-devkit/build-angular): avoid attempting to optimize copied JavaScript assets | +| [4f757c2bc](https://github.com/angular/angular-cli/commit/4f757c2bcf1356d33eaa86bc3b715c0a6b7c2ed8) | fix(@angular-devkit/build-angular): handle null maps in JavaScript optimizer worker | + +## Special Thanks: + +Alan Agius and Charles Lyding + + + + + +# 12.2.0 (2021-08-04) + +### @angular/cli + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| [259e26979](https://github.com/angular/angular-cli/commit/259e26979ebc712ee08fd36fb68a9576c1e02447) | fix(@angular/cli): merge npmrc files values | +| [c1eddbdc9](https://github.com/angular/angular-cli/commit/c1eddbdc98631fdfff287ce566d79ed43b601e0f) | fix(@angular/cli): handle `YARN_` environment variables during `ng update` and `ng add` | +| [6b00d1270](https://github.com/angular/angular-cli/commit/6b00d1270acaf33f32ee68c4254ce06951ddcb8c) | fix(@angular/cli): handle NPM_CONFIG environment variables during ng update and ng add | +| [88ee85c41](https://github.com/angular/angular-cli/commit/88ee85c4178e37b72001e8946b70a46ba739a0b7) | fix(@angular/cli): disable update notifier when retrieving package manager version during `ng version` | + +### @angular-devkit/build-angular + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| [d750c686f](https://github.com/angular/angular-cli/commit/d750c686fd26f3ccfccb039027bd816a91279497) | fix(@angular-devkit/build-angular): add priority to copy-webpack-plugin patterns | +| [4bcd1dc9e](https://github.com/angular/angular-cli/commit/4bcd1dc9ee744343a465d73d51d4a062964a3714) | fix(@angular-devkit/build-angular): allow classes with pure annotated static properties to be optimized | +| [ceade0c27](https://github.com/angular/angular-cli/commit/ceade0c27e4b8b0e731e6ca5128fd86cf071d029) | fix(@angular-devkit/build-angular): dasherize disable-host-check suggestion | +| [8383c6b42](https://github.com/angular/angular-cli/commit/8383c6b421f7005a25a3bff0826048f3a24f3030) | fix(@angular-devkit/build-angular): silence Sass compiler warnings from 3rd party stylesheets | +| [07763702f](https://github.com/angular/angular-cli/commit/07763702fd244ba44aebb714a295dbf5ba72b91d) | fix(@angular-devkit/build-angular): force linker `sourceMapping` option to false. | +| [a5c69722f](https://github.com/angular/angular-cli/commit/a5c69722ffeceb72dcd46901c2bb983e5dc8bf32) | fix(@angular-devkit/build-angular): ensure `NG_PERSISTENT_BUILD_CACHE` always creates a cache in the specified cache directory | +| [c65b04999](https://github.com/angular/angular-cli/commit/c65b049996a8de9d9fcc66631872424cbe5f13f9) | fix(@angular-devkit/build-angular): fail browser build when index generation fails | +| [3d71c63b3](https://github.com/angular/angular-cli/commit/3d71c63b3a11946ebfca3f0d97d4fbf8dca16255) | fix(@angular-devkit/build-angular): fix issue were `@media all` causing critical CSS inling to fail | +| [9a04975a2](https://github.com/angular/angular-cli/commit/9a04975a2170c3ecc2c09c32bd15a89c613e198f) | fix(@angular-devkit/build-angular): `extractLicenses` didn't have an effect when using server builder | +| [2ac8e9c0e](https://github.com/angular/angular-cli/commit/2ac8e9c0e131bf7fcb2c6e92500eeaa112efcefb) | fix(@angular-devkit/build-angular): display incompatibility errors | +| [2c2b49919](https://github.com/angular/angular-cli/commit/2c2b499193fb319e1c9cb92318610353b7720e2b) | fix(@angular-devkit/build-angular): limit advanced terser passes to two | +| [1be3b0783](https://github.com/angular/angular-cli/commit/1be3b07836659487e4aa9b8c71c673635e268a60) | fix(@angular-devkit/build-angular): exclude `outputPath` from persistent build cache key | +| [fefd6d042](https://github.com/angular/angular-cli/commit/fefd6d04213e61d3f48c0484d8c6a8dcff1ecd34) | perf(@angular-devkit/build-angular): use `esbuild` as a CSS optimizer for component styles | +| [18cfa0431](https://github.com/angular/angular-cli/commit/18cfa04317230f934ccba798c080543bb389725f) | feat(@angular-devkit/build-angular): add support to inline Adobe Fonts | +| [9a751f0f8](https://github.com/angular/angular-cli/commit/9a751f0f81919d67f5eeeaecbe807d5c216f6a7a) | fix(@angular-devkit/build-angular): handle `ENOENT` and `ENOTDIR` errors when deleting outputs | +| [41e645792](https://github.com/angular/angular-cli/commit/41e64579213b9d4a7c976ea45daa6b32d980df10) | fix(@angular-devkit/build-angular): downlevel `for await...of` when targeting ES2018+ | +| [070a13364](https://github.com/angular/angular-cli/commit/070a1336478d721bbbb474622f50fab455cda26c) | fix(@angular-devkit/build-angular): configure webpack target in common configuration | +| [da32daa75](https://github.com/angular/angular-cli/commit/da32daa75d08d4be177af5fa16088398d7fb427b) | perf(@angular-devkit/build-angular): use combination of `esbuild` and `terser` as a JavaScript optimizer | +| [6a2b11906](https://github.com/angular/angular-cli/commit/6a2b11906e4173562a82b3654ff662dd05513049) | perf(@angular-devkit/build-angular): cache JavaScriptOptimizerPlugin results | +| [ab17b1721](https://github.com/angular/angular-cli/commit/ab17b1721c05366e592cf805ad6d25e672b314bf) | fix(@angular-devkit/build-angular): handle ng-packagr errors more gracefully. | +| [d4c5f8518](https://github.com/angular/angular-cli/commit/d4c5f8518d4801b9fd76de289a015dcbb8d8f69b) | fix(@angular-devkit/build-angular): control linker template sourcemapping via builder sourcemap options | +| [06181c2fb](https://github.com/angular/angular-cli/commit/06181c2fbf5a20396b2d0e2b3925ceb1276947fb) | fix(@angular-devkit/build-angular): parse web-workers in tests when webWorkerTsConfig is defined | + +### @angular-devkit/build-webpack + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| [615353022](https://github.com/angular/angular-cli/commit/61535302204a2a767f85053b7efaa6ac5ac64098) | fix(@angular-devkit/build-webpack): emit result when webpack is closed | + +### @ngtools/webpack + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| [dbbcf5c8c](https://github.com/angular/angular-cli/commit/dbbcf5c8c4ec4427609942f4ef7053c1b51773c9) | fix(@ngtools/webpack): only track file dependencies | +| [7536338e0](https://github.com/angular/angular-cli/commit/7536338e0becc7f9cde62becbde58e18a270cb31) | fix(@ngtools/webpack): allow generated assets of Angular component resources | +| [720feee34](https://github.com/angular/angular-cli/commit/720feee34f910fc11c40e2f68d919d61b7d6cbec) | fix(@ngtools/webpack): avoid non-actionable template type-checker syntax diagnostics | +| [6a7bcf330](https://github.com/angular/angular-cli/commit/6a7bcf3300b459aef80fcf98f2475c977f6244dc) | fix(@ngtools/webpack): encode component style data | +| [12c14b565](https://github.com/angular/angular-cli/commit/12c14b56537d65d6986e245ab1ae4dd9aa8dd378) | fix(@ngtools/webpack): remove no longer needed component styles workaround | + +### @schematics/angular + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| [20fd33f6d](https://github.com/angular/angular-cli/commit/20fd33f6d4ce6cef1feb508a0221222e83a85630) | feat(@schematics/angular): destroy test module after every test | +| [5b10d4f54](https://github.com/angular/angular-cli/commit/5b10d4f549ebc12645ad08cba8ab7b91eaa87d28) | fix(@schematics/angular): remove unsafe any usage in application spec file | +| [1b5e18e7b](https://github.com/angular/angular-cli/commit/1b5e18e7b401efb7ec73d99c4d77d9b29e956724) | fix(@schematics/angular): replace interactive `div` with `button` in application component template | +| [0907b6941](https://github.com/angular/angular-cli/commit/0907b694174d6d684d965baf6cd37b87f49742e8) | fix(@schematics/angular): use stricter semver for `karma-jasmine-html-reporter` | +| [8ad1539c5](https://github.com/angular/angular-cli/commit/8ad1539c5e73bad30eb6eb340379d64db208098c) | fix(@schematics/angular): add 'none' value for the 'style' option of the component schematic | +| [e5ba29c7d](https://github.com/angular/angular-cli/commit/e5ba29c7d54cbd83057cf23a21119ea5a3146993) | fix(@schematics/angular): display warning during migrations when using third-party builders | +| [a44dc02fe](https://github.com/angular/angular-cli/commit/a44dc02feecaf8735f2dc6128a5b6cc5666b4434) | fix(@schematics/angular): add devtools to ng new | + +## Special Thanks: + +Alan Agius, Charles Lyding, David Scourfield, Doug Parker, hien-pham, Joey Perrott, LeonEck, Mike +Jancar, twerske, Vaibhav Singh and originalfrostig + + + + + +# 12.2.0-rc.0 (2021-07-28) + +### @angular/cli + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| [259e26979](https://github.com/angular/angular-cli/commit/259e26979ebc712ee08fd36fb68a9576c1e02447) | fix(@angular/cli): merge npmrc files values | + +### @angular-devkit/build-angular + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| [d750c686f](https://github.com/angular/angular-cli/commit/d750c686fd26f3ccfccb039027bd816a91279497) | fix(@angular-devkit/build-angular): add priority to copy-webpack-plugin patterns | + +### @angular-devkit/build-webpack + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| [615353022](https://github.com/angular/angular-cli/commit/61535302204a2a767f85053b7efaa6ac5ac64098) | fix(@angular-devkit/build-webpack): emit result when webpack is closed | + +## Special Thanks: + +Alan Agius, Charles Lyding, Joey Perrott and originalfrostig + + + + + +# 12.1.4 (2021-07-28) + +### @angular/cli + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| [e02c97dd0](https://github.com/angular/angular-cli/commit/e02c97dd09399443438b32cf1ad47fa0f7011df3) | fix(@angular/cli): merge npmrc files values | + +### @schematics/angular + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| [cfc267426](https://github.com/angular/angular-cli/commit/cfc267426716e9ecf0c9833720cb35298284f699) | fix(@schematics/angular): ensure valid SemVer range for new project Angular packages | + +### @angular-devkit/build-angular + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| [55c0bddc8](https://github.com/angular/angular-cli/commit/55c0bddc8b2425309f00733eca96c06f60f867d5) | fix(@angular-devkit/build-angular): add priority to copy-webpack-plugin patterns | + +### @angular-devkit/build-webpack + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| [b3736a3c0](https://github.com/angular/angular-cli/commit/b3736a3c09f39f5ee5dc12d98535fe4b6803ea3b) | fix(@angular-devkit/build-webpack): emit result when webpack is closed | + +## Special Thanks: + +Alan Agius, Charles Lyding, Joey Perrott and originalfrostig + + + + + +# 12.2.0-next.3 (2021-07-21) + +### @angular/cli + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| [c1eddbdc9](https://github.com/angular/angular-cli/commit/c1eddbdc98631fdfff287ce566d79ed43b601e0f) | fix(@angular/cli): handle `YARN_` environment variables during `ng update` and `ng add` | +| [6b00d1270](https://github.com/angular/angular-cli/commit/6b00d1270acaf33f32ee68c4254ce06951ddcb8c) | fix(@angular/cli): handle NPM_CONFIG environment variables during ng update and ng add | + +### @angular-devkit/build-angular + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| [4bcd1dc9e](https://github.com/angular/angular-cli/commit/4bcd1dc9ee744343a465d73d51d4a062964a3714) | fix(@angular-devkit/build-angular): allow classes with pure annotated static properties to be optimized | +| [ceade0c27](https://github.com/angular/angular-cli/commit/ceade0c27e4b8b0e731e6ca5128fd86cf071d029) | fix(@angular-devkit/build-angular): dasherize disable-host-check suggestion | + +## Special Thanks: + +Alan Agius, Charles Lyding, Joey Perrott, LeonEck and Mike Jancar + + + + + +# 12.1.3 (2021-07-21) + +### @angular/cli + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| [eaa2378b6](https://github.com/angular/angular-cli/commit/eaa2378b6bc69a2485cce742ef95b0b94ae994c6) | fix(@angular/cli): handle `YARN_` environment variables during `ng update` and `ng add` | +| [4b9a41bde](https://github.com/angular/angular-cli/commit/4b9a41bdedcdc4e115e8956d31126c5bf6f442ca) | fix(@angular/cli): handle NPM_CONFIG environment variables during ng update and ng add | + +### @angular-devkit/build-angular + +| Commit | Description | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| [04e9ffe4f](https://github.com/angular/angular-cli/commit/04e9ffe4f6b262ce5ef630310bed318e1466d238) | fix(@angular-devkit/build-angular): allow classes with pure annotated static properties to be optimized | +| [6ae17e265](https://github.com/angular/angular-cli/commit/6ae17e26547a0174f7a8910c514016db60fe4c7a) | fix(@angular-devkit/build-angular): dasherize disable-host-check suggestion | + +## Special Thanks: + +Alan Agius, Charles Lyding, Joey Perrott, LeonEck and Mike Jancar + + + + + +# v12.2.0-next.2 (2021-07-14) + +# Commits + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

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

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

@ngtools/webpack (12.2.0-next.2)

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

@schematics/angular (12.2.0-next.2)

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

@angular-devkit/build-angular (12.1.2)

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

@ngtools/webpack (12.1.2)

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

@schematics/angular (12.1.2)

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

@angular-devkit/build-angular (12.1.1)

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

@angular/cli (12.1.1)

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

@ngtools/webpack (12.1.1)

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

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

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

@angular/cli (12.2.0-next.1)

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

@ngtools/webpack (12.2.0-next.1)

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

@angular-devkit/build-angular (12.1.0)

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

@ngtools/webpack (12.1.0)

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

@schematics/angular (12.1.0)

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

@angular-devkit/build-angular (12.1.0)

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

@angular/cli (12.1.0)

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

@ngtools/webpack (12.1.0)

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

@schematics/angular (12.1.0)

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

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

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

@angular/cli (12.1.0-next.6)

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

@schematics/angular (12.1.0-next.6)

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

@angular-devkit/build-angular (12.0.5)

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

@angular/cli (12.0.5)

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

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

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

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

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

@angular/cli (12.1.0-next.5)

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

@ngtools/webpack (12.1.0-next.5)

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

@schematics/angular (12.1.0-next.5)

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

@angular-devkit/build-angular (12.0.4)

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

@angular-devkit/schematics (12.0.4)

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

@angular/cli (12.0.4)

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

@ngtools/webpack (12.0.4)

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

@schematics/angular (12.0.4)

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

@angular-devkit/build-angular (12.0.3)

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

@angular-devkit/build-webpack (0.1200.3)

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

@angular-devkit/core (12.0.3)

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

@angular/cli (12.0.3)

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

@ngtools/webpack (12.0.3)

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

@schematics/angular (12.0.3)

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

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

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

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

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

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

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

@angular/cli (12.1.0-next.4)

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

@ngtools/webpack (12.1.0-next.4)

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

@schematics/angular (12.1.0-next.4)

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

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

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

@angular/cli (12.1.0-next.3)

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

@ngtools/webpack (12.1.0-next.3)

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

@angular-devkit/build-angular (12.0.2)

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

@angular/cli (12.0.2)

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

@ngtools/webpack (12.0.2)

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

@angular-devkit/build-angular (12.0.1)

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

@angular/cli (12.0.1)

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

@ngtools/webpack (12.0.1)

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

@schematics/angular (12.0.1)

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

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

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

@angular/cli (12.1.0-next.2)

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

@ngtools/webpack (12.1.0-next.2)

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

@schematics/angular (12.1.0-next.2)

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

@angular-devkit/architect (0.1200.0)

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

@angular-devkit/build-angular (12.0.0)

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

@angular-devkit/build-optimizer (0.1200.0)

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

@angular-devkit/build-webpack (0.1200.0)

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

@angular-devkit/core (12.0.0)

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

@angular/cli (12.0.0)

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

@ngtools/webpack (12.0.0)

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

@schematics/angular (12.0.0)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@angular/cli (12.0.0-rc.3)

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

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

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

@angular/cli (12.0.0-rc.2)

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

@ngtools/webpack (12.0.0-rc.2)

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

@schematics/angular (12.0.0-rc.2)

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

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

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

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

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

@angular/cli (12.0.0-rc.1)

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

@schematics/angular (12.0.0-rc.1)

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

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

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

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

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

@angular/cli (12.0.0-rc.0)

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

@ngtools/webpack (12.0.0-rc.0)

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

@schematics/angular (12.0.0-rc.0)

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

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

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

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

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

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

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

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

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

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

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

@angular/cli (12.0.0-next.9)

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

@ngtools/webpack (12.0.0-next.9)

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

@schematics/angular (12.0.0-next.9)

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

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

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

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

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

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

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

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

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

@ngtools/webpack (12.0.0-next.8)

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

@schematics/angular (12.0.0-next.8)

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

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

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

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

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

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

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

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

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

@angular/cli (12.0.0-next.7)

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

@ngtools/webpack (12.0.0-next.7)

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

@schematics/angular (12.0.0-next.7)

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

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

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

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

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

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

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

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

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

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

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

@schematics/angular (12.0.0-next.6)

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

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

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

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

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

@angular/cli (12.0.0-next.5)

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

@ngtools/webpack (12.0.0-next.5)

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

@schematics/angular (12.0.0-next.5)

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

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

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

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

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

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

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

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

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

@angular/cli (12.0.0-next.4)

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

@ngtools/webpack (12.0.0-next.4)

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

@schematics/angular (12.0.0-next.4)

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

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

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

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

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

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

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

@schematics/angular (12.0.0-next.3)

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

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

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

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

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

@angular/cli (12.0.0-next.2)

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

@schematics/angular (12.0.0-next.2)

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

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

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

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

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

@angular/cli (12.0.0-next.1)

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

@ngtools/webpack (12.0.0-next.1)

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

@schematics/angular (12.0.0-next.1)

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

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

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

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

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

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

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

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

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

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

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

@angular/cli (12.0.0-next.0)

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

@ngtools/webpack (12.0.0-next.0)

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

@schematics/angular (12.0.0-next.0)

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

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

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

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

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

Angular CLI - The CLI tool for Angular.

-This is the home of the DevKit and the Angular CLI code. You can find the Angular CLI specific README -[here](https://github.com/angular/angular-cli/blob/master/packages/angular/cli/README.md). +

+
+ Angular CLI logo +

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

+

+ angular.dev/tools/cli +
+

-[![CircleCI branch](https://img.shields.io/circleci/project/github/angular/angular-cli/master.svg?label=circleci)](https://circleci.com/gh/angular/angular-cli) [![Dependency Status](https://david-dm.org/angular/angular-cli.svg)](https://david-dm.org/angular/angular-cli) [![devDependency Status](https://david-dm.org/angular/angular-cli/dev-status.svg)](https://david-dm.org/angular/angular-cli?type=dev) +

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

-[![License](https://img.shields.io/npm/l/@angular/cli.svg)](https://github.com/angular/angular-cli/blob/master/LICENSE) +
-[![GitHub forks](https://img.shields.io/github/forks/angular/angular-cli.svg?style=social&label=Fork)](https://github.com/angular/angular-cli/fork) [![GitHub stars](https://img.shields.io/github/stars/angular/angular-cli.svg?style=social&label=Star)](https://github.com/angular/angular-cli) +## Documentation +Get started with Angular CLI, learn the fundamentals and explore advanced topics on our documentation website. +- [Getting started][quickstart] +- [CLI][cli] +- [Workspace and project file structure][filestructure] +- [Workspace configuration][workspaceconfig] +- [Schematics][schematics] -### Quick Links -[Gitter](https://gitter.im/angular/angular-cli) | [Contributing](https://github.com/angular/angular-cli/blob/master/CONTRIBUTING.md) | [Angular CLI](http://github.com/angular/angular-cli) | -|---|---|---| +## Development Setup ----- -## The Goal of Angular CLI +### Prerequisites -The Angular CLI creates, manages, builds and test your Angular projects. It's built on top of the -Angular DevKit. +- Install [Node.js] which includes [Node Package Manager][npm] -## The Goal of DevKit +### Setting Up a Project -DevKit's goal is to provide a large set of libraries that can be used to manage, develop, deploy and -analyze your code. +Install the Angular CLI globally: -# Getting Started - Local Development +``` +npm install -g @angular/cli +``` -## Installation -To get started locally, follow these instructions: +Create workspace: -1. If you haven't done it already, [make a fork of this repo](https://github.com/angular/angular-cli/fork). -1. Clone to your local computer using `git`. -1. Make sure that you have Node 10.9 or later installed. See instructions [here](https://nodejs.org/en/download/). The Angular CLI requires Node 8, but development requires Node 10. -1. Make sure that you have `yarn` installed; see instructions [here](https://yarnpkg.com/lang/en/docs/install/). -1. Run `yarn` (no arguments) from the root of your clone of this project. -1. Run `yarn link` to add all custom scripts we use to your global install. +``` +ng new [PROJECT NAME] +``` -## Creating New Packages -Adding a package to this repository means running two separate commands: +Run the application: -1. `schematics devkit:package PACKAGE_NAME`. This will update the `.monorepo` file, and create the - base files for the new package (package.json, src/index, etc). -1. `devkit-admin templates`. This will update the README and all other template files that might - have changed when adding a new package. +``` +cd [PROJECT NAME] +ng serve +``` -For private packages, you will need to add a `"private": true` key to your package.json manually. -This will require re-running the template admin script. +Angular is cross-platform, fast, scalable, has incredible tooling, and is loved by millions. -# Packages +## Quickstart + +[Get started in 5 minutes][quickstart]. + +## Ecosystem + +

+ angular ecosystem logos +

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

- --aot default value: false -

-

- Build using Ahead of Time compilation. -

-
- -
- app -

- --app (aliases: -a) -

-

- Specifies app name or index to use. -

-
- -
- base-href -

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

-

- Base url for the application being built. -

-
- -
- deploy-url -

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

-

- URL where files will be deployed. -

-
- -
- environment -

- --environment (aliases: -e) -

-

- Defines the build environment. -

-
- -
- extract-css -

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

-

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

-
- -
- i18n-file -

- --i18n-file -

-

- Localization file to use for i18n. -

-
- -
- i18n-format -

- --i18n-format -

-

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

-
- -
- locale -

- --locale -

-

- Locale to use for i18n. -

-
- -
- missing-translation -

- --missing-translation -

-

- How to handle missing translations for i18n. -

-

- Values: error, warning, ignore -

-
- -
- output-hashing -

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

-

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

-

- Values: none, all, media, bundles -

-
- -
- output-path -

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

-

- Path where output will be placed. -

-
- -
- delete-output-path -

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

-

- Delete the output-path directory. -

-
- -
- poll -

- --poll -

-

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

-
- -
- progress -

- --progress (aliases: -pr) default value: true inside TTY, false otherwise -

-

- Log progress to the console while building. -

-
- -
- sourcemap -

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

-

- Output sourcemaps. -

-
- -
- stats-json -

- --stats-json -

-

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

-
- -
- target -

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

-

- Defines the build target. -

-
- -
- vendor-chunk -

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

-

- Use a separate bundle containing only vendor libraries. -

-
- -
- common-chunk -

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

-

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

-
- -
- verbose -

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

-

- Adds more details to output logging. -

-
- -
- watch -

- --watch (aliases: -w) -

-

- Run build when files change. -

-
- -
- show-circular-dependencies -

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

-

- Show circular dependency warnings on builds. -

-
- -
- build-optimizer -

- --build-optimizer -

-

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

-
- -
- named-chunks -

- --named-chunks (aliases: -nc) -

-

- Use file name for lazy loaded chunks. -

-
- -
- bundle-dependencies -

- --bundle-dependencies -

-

- In a server build, state whether `all` or `none` dependencies should be bundles in the output. -

-
- - -
- extract-licenses -

- --extract-licenses default value: true -

-

- Extract all licenses in a separate file, in the case of production builds only. -

-
diff --git a/docs/documentation/1-x/config.md b/docs/documentation/1-x/config.md deleted file mode 100644 index b114b60c0d7d..000000000000 --- a/docs/documentation/1-x/config.md +++ /dev/null @@ -1,20 +0,0 @@ - - -# ng config - -## Overview -`ng config [key] [value]` Get/set configuration values. -`[key]` should be in JSON path format. Example: `a[3].foo.bar[2]`. -If only the `[key]` is provided it will get the value. -If both the `[key]` and `[value]` are provided it will set the value. - -## Options -
- global -

- --global default value: false -

-

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

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

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

-

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

-
diff --git a/docs/documentation/1-x/e2e.md b/docs/documentation/1-x/e2e.md deleted file mode 100644 index 6e638298b67d..000000000000 --- a/docs/documentation/1-x/e2e.md +++ /dev/null @@ -1,81 +0,0 @@ - - -# ng e2e - -## Overview -`ng e2e` serves the application and runs end-to-end tests - -### Running end-to-end tests - -```bash -ng e2e -``` - -End-to-end tests are run via [Protractor](http://www.protractortest.org/). - -## Options - -Please note that options that are supported by `ng serve` are also supported by `ng e2e` - -
- config -

- --config (aliases: -c) -

-

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

-
- -
- element-explorer -

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

-

- Start Protractor's Element Explorer for debugging. -

-
- -
- serve -

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

-

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

-

- NOTE: Build failure will not launch the e2e task. You must first fix error(s) and run e2e again. -

-
- -
- specs -

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

-

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

-
- -
- suite -

- --suite (aliases: -su) -

-

- Override suite in the protractor config. Can send in multiple suite by comma separated values (ng e2e --suite=suiteA,suiteB). -

-
- -
- webdriver-update -

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

-

- Try to update webdriver. -

-
diff --git a/docs/documentation/1-x/eject.md b/docs/documentation/1-x/eject.md deleted file mode 100644 index d1c55e34a6a0..000000000000 --- a/docs/documentation/1-x/eject.md +++ /dev/null @@ -1,233 +0,0 @@ - - -# ng eject - -## Overview -`ng eject` ejects your app and output the proper webpack configuration and scripts. - -This command uses the same flags as `ng build`, generating webpack configuration to match those -flags. - -You can use `--force` to overwrite existing configurations. -You can eject multiple times, to have a dev and prod config for instance, by renaming the ejected -configuration and using the `--force` flag. - -### Ejecting the CLI - -```bash -ng eject -``` - -## Options -
- aot -

- --aot -

-

- Build using Ahead of Time compilation. -

-
- -
- app -

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

-

- Specifies app name to use. -

-
- -
- base-href -

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

-

- Base url for the application being built. -

-
- -
- deploy-url -

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

-

- URL where files will be deployed. -

-
- -
- environment -

- --environment (aliases: -e) -

-

- Defines the build environment. -

-
- -
- extract-css -

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

-

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

-
- -
- force -

- --force default value: false -

-

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

-
- -
- i18n-file -

- --i18n-file -

-

- Localization file to use for i18n. -

-
- -
- i18n-format -

- --i18n-format -

-

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

-
- -
- locale -

- --locale -

-

- Locale to use for i18n. -

-
- -
- missing-translation -

- --missing-translation -

-

- How to handle missing translations for i18n. -

-

- Values: error, warning, ignore -

-
- -
- output-hashing -

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

-

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

-
- -
- output-path -

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

-

- Path where output will be placed. -

-
- -
- poll -

- --poll -

-

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

-
- -
- progress -

- --progress (aliases: -pr) default value: true inside TTY, false otherwise -

-

- Log progress to the console while building. -

-
- -
- sourcemap -

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

-

- Output sourcemaps. -

-
- -
- target -

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

-

- Defines the build target. -

-
- -
- vendor-chunk -

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

-

- Use a separate bundle containing only vendor libraries. -

-
- -
- common-chunk -

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

-

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

-
- -
- verbose -

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

-

- Adds more details to output logging. -

-
- -
- watch -

- --watch (aliases: -w) -

-

- Run build when files change. -

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

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

-

- Run through without making any changes. -

-
- -
- force -

- --force (aliases: -f) default value: false -

-

- Forces overwriting of files. -

-
- -
- app -

- --app -

-

- Specifies app name to use. -

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

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

-

- Specifies app name to use. -

-
- -
- spec -

- --spec -

-

- Specifies if a spec file is generated. -

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

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

-

- Specifies app name to use. -

-
- -
- change-detection -

- --change-detection (aliases: -c) -

-

- Specifies the change detection strategy. -

-
- -
- flat -

- --flat default value: false -

-

- Flag to indicate if a dir is created. -

-
- -
- export -

- --export default value: false -

-

- Specifies if declaring module exports the component. -

-
- -
- inline-style -

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

-

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

-
- -
- inline-template -

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

-

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

-
- -
- module -

- --module (aliases: -m) -

-

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

-
- -
- prefix -

- --prefix -

-

- Specifies whether to use the prefix. -

-
- -
- skip-import -

- --skip-import default value: false -

-

- Allows for skipping the module import. -

-
- -
- spec -

- --spec -

-

- Specifies if a spec file is generated. -

-
- -
- view-encapsulation -

- --view-encapsulation (aliases: -v) -

-

- Specifies the view encapsulation strategy. -

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

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

-

- Specifies app name to use. -

-
- -
- export -

- --export default value: false -

-

- Specifies if declaring module exports the component. -

-
- -
- flat -

- --flat -

-

- Flag to indicate if a dir is created. -

-
- -
- module -

- --module (aliases: -m) -

-

- Allows specification of the declaring module. -

-
- -
- prefix -

- --prefix -

-

- Specifies whether to use the prefix. -

-
- -
- skip-import -

- --skip-import -

-

- Allows for skipping the module import. -

-
- -
- spec -

- --spec -

-

- Specifies if a spec file is generated. -

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

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

-

- Specifies app name to use. -

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

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

-

- Specifies app name to use. -

-
- -
- flat -

- --flat -

-

- Indicate if a dir is created. -

-
- -
- module -

- --module (aliases: -m) -

-

- Specifies where the guard should be provided. -

-
- -
- spec -

- --spec -

-

- Specifies if a spec file is generated. -

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

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

-

- Specifies app name to use. -

-
- -
- type -

- Optional String to specify the type of interface. -

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

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

-

- Specifies app name to use. -

-
- -
- flat -

- --flat -

-

- Flag to indicate if a dir is created. -

-
- -
- module -

- --module (aliases: -m) -

-

- Specifies where the module should be imported. -

-
- -
- spec -

- --spec -

-

- Specifies if a spec file is generated. -

-
- -
- routing -

- --routing -

-

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

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

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

-

- Specifies app name to use. -

-
- -
- export -

- --export -

-

- Specifies if declaring module exports the pipe. -

-
- -
- flat -

- --flat -

-

- Flag to indicate if a dir is created. -

-
- -
- module -

- --module (aliases: -m) -

-

- Allows specification of the declaring module. -

-
- -
- skip-import -

- --skip-import -

-

- Allows for skipping the module import. -

-
- -
- spec -

- --spec -

-

- Specifies if a spec file is generated. -

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

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

-

- Specifies app name to use. -

-
- -
- flat -

- --flat -

-

- Flag to indicate if a dir is created. -

-
- -
- module -

- --module (aliases: -m) -

-

- Specifies where the service should be provided. -

-
- -
- spec -

- --spec -

-

- Specifies if a spec file is generated. -

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

- --fix default value: false -

-

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

-
- -
- force -

- --force default value: false -

-

- Succeeds even if there was linting errors. -

-
- -
- type-check -

- --type-check default value: false -

-

- Controls the type check for linting. -

-
- -
- format -

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

-

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

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

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

-

- The directory name to create the app in. -

-
- -
- dry-run -

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

-

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

-
- -
- inline-style -

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

-

- Should have an inline style. -

-
- -
- inline-template -

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

-

- Should have an inline template. -

-
- -
- minimal -

- --minimal default value: false -

-

- Should create a minimal app. -

-
- -
- prefix -

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

-

- The prefix to use for all component selectors. -

-

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

-
- -
- routing -

- --routing default value: false -

-

- Generate a routing module. -

-
- -
- skip-commit -

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

-

- Skip committing the first commit to git. -

-
- -
- skip-git -

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

-

- Skip initializing a git repository. -

-
- -
- skip-install -

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

-

- Skip installing packages. -

-
- -
- skip-tests -

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

-

- Skip creating spec files. -

-

- Skip including e2e functionality. -

-
- -
- source-dir -

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

-

- The name of the source directory. -

-

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

-
- -
- style -

- --style default value: css -

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

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

-
- -
- verbose -

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

-

- Adds more details to output logging. -

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

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

-

- Listens only on localhost by default. -

-
- -
- hmr -

- --hmr default value: false -

-

- Enable hot module replacement. -

-
- -
- live-reload -

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

-

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

-
- -
- public-host -

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

-

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

-
- -
- disable-host-check -

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

-

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

-
- -
- open -

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

-

- Opens the url in default browser. -

-
- -
- port -

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

-

- Port to listen to for serving. --port 0 will get a free port -

-
- -
- ssl -

- --ssl -

-

- Serve using HTTPS. -

-
- -
- ssl-cert -

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

-

- SSL certificate to use for serving HTTPS. -

-
- -
- ssl-key -

- --ssl-key -

-

- SSL key to use for serving HTTPS. -

-
- -
- aot -

- --aot -

-

- Build using Ahead of Time compilation. -

-
- -
- base-href -

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

-

- Base url for the application being built. -

-
- -
- deploy-url -

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

-

- URL where files will be deployed. -

-
- -
- environment -

- --environment (aliases: -e) -

-

- Defines the build environment. -

-
- -
- extract-css -

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

-

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

-
- -
- i18n-file -

- --i18n-file -

-

- Localization file to use for i18n. -

-
- -
- i18n-format -

- --i18n-format -

-

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

-
- -
- locale -

- --locale -

-

- Locale to use for i18n. -

-
- -
- missing-translation -

- --missing-translation -

-

- How to handle missing translations for i18n. -

-

- Values: error, warning, ignore -

-
- -
- output-hashing -

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

-

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

-
- -
- output-path -

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

-

- Path where output will be placed. -

-
- -
- poll -

- --poll -

-

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

-
- -
- progress -

- --progress (aliases: -pr) default value: true inside TTY, false otherwise -

-

- Log progress to the console while building. -

-
- -
- proxy-config -

- --proxy-config (aliases: -pc) -

-

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

-
- -
- sourcemap -

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

-

- Output sourcemaps. -

-
- -
- target -

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

-

- Defines the build target. -

-
- -
- vendor-chunk -

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

-

- Use a separate bundle containing only vendor libraries. -

-
- -
- common-chunk -

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

-

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

-
- -
- verbose -

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

-

- Adds more details to output logging. -

-
- -
- watch -

- --watch (aliases: -w) -

-

- Run build when files change. -

-
- - -## Note -When running `ng serve`, the compiled output is served from memory, not from disk. This means that the application being served is not located on disk in the `dist` folder. diff --git a/docs/documentation/1-x/stories.md b/docs/documentation/1-x/stories.md deleted file mode 100644 index d602d59e328c..000000000000 --- a/docs/documentation/1-x/stories.md +++ /dev/null @@ -1,34 +0,0 @@ - - -# Stories describing how to do more with the CLI - - - [1.0 Update](1-x/stories/1.0-update) - - [Asset Configuration](1-x/stories/asset-configuration) - - [Autocompletion](1-x/stories/autocompletion) - - [Configure Hot Module Replacement](1-x/stories/configure-hmr) - - [CSS Preprocessors](1-x/stories/css-preprocessors) - - [Global Lib](1-x/stories/global-lib) - - [Global Scripts](1-x/stories/global-scripts) - - [Global Styles](1-x/stories/global-styles) - - [Angular Flex Layout](1-x/stories/include-angular-flex) - - [Angular Material](1-x/stories/include-angular-material) - - [AngularFire](1-x/stories/include-angularfire) - - [Bootstrap](1-x/stories/include-bootstrap) - - [Budgets](1-x/stories/budgets) - - [Font Awesome](1-x/stories/include-font-awesome) - - [Moving Into the CLI](1-x/stories/moving-into-the-cli) - - [Moving Out of the CLI](1-x/stories/moving-out-of-the-cli) - - [Proxy](1-x/stories/proxy) - - [Routing](1-x/stories/routing) - - [3rd Party Lib](1-x/stories/third-party-lib) - - [Corporate Proxy](1-x/stories/using-corporate-proxy) - - [Internationalization (i18n)](1-x/stories/internationalization) - - [Serve from Disk](1-x/stories/disk-serve) - - [Code Coverage](1-x/stories/code-coverage) - - [Application Environments](1-x/stories/application-environments) - - [Autoprefixer Configuration](1-x/stories/autoprefixer) - - [Deploy to GitHub Pages](1-x/stories/github-pages) - - [Linked Library](1-x/stories/linked-library) - - [Multiple apps](1-x/stories/multiple-apps) - - [Continuous Integration](1-x/stories/continuous-integration) - - [Universal Rendering](1-x/stories/universal-rendering) diff --git a/docs/documentation/1-x/stories/1.0-update.md b/docs/documentation/1-x/stories/1.0-update.md deleted file mode 100644 index 9a32e4555ab9..000000000000 --- a/docs/documentation/1-x/stories/1.0-update.md +++ /dev/null @@ -1,503 +0,0 @@ -# Angular CLI migration guide - -In this migration guide we'll be looking at some of the major changes to CLI projects in the -last two months. - -Most of these changes were not breaking changes and your project should work fine without them. - -But if you've been waiting for the perfect time to update, this is it! -Along with major rebuild speed increases, we've been busy adding a lot of features. - -Documentation has also completely moved to [the wiki](https://github.com/angular/angular-cli/wiki). -The new [Stories](https://github.com/angular/angular-cli/wiki/stories) section covers common usage -scenarios, so be sure to have a look! - -Below are the changes between a project generated two months ago, with `1.0.0-beta.24` and -a `1.0.0` project. -If you kept your project up to date you might have a lot of these already. - -You can find more details about changes between versions in [the releases tab on GitHub](https://github.com/angular/angular-cli/releases). - -If you prefer, you can also generate a new project in a separate folder using - `ng new upgrade-project --skip-install` and compare the differences. - -## @angular/cli - -Angular CLI can now be found on NPM under `@angular/cli` instead of `angular-cli`, and upgrading is a simple 3 step process: - -1. Uninstall old version -2. Update node/npm if necessary -3. Install new version - -### 1. Uninstall old version - -If you're using Angular CLI `beta.28` or less, you need to uninstall the `angular-cli` packages: -```bash -npm uninstall -g angular-cli # Remove global package -npm uninstall --save-dev angular-cli # Remove from package.json -``` - -Otherwise, uninstall the `@angular/cli` packages: -```bash -npm uninstall -g @angular/cli # Remove global package -npm uninstall --save-dev @angular/cli # Remove from package.json -``` - -Also purge the cache and local packages: -``` -rm -rf node_modules dist # Use rmdir on Windows -npm cache clean -``` - -At this point, you should not have Angular CLI on your system anymore. If invoking Angular CLI at the commandline reveals that it still exists on your system, you will have to manually remove it. See _Manually removing residual Angular CLI_. - -### 2. Update node/npm if necessary - -Angular CLI now has a minimum requirement of Node 6.9.0 or higher, together with NPM 3 or higher. - -If your Node or NPM versions do not meet these requirements, please refer to [the documentation](https://docs.npmjs.com/getting-started/installing-node) on how to upgrade. - -### 3. Install the new version - -To update Angular CLI to a new version, you must update both the global package and your project's local package: - -```bash -npm install -g @angular/cli@latest # Global package -npm install --save-dev @angular/cli@latest # Local package -npm install # Restore removed dependencies -``` - -### Manually removing residual Angular CLI - -If you accidentally updated NPM before removing the old Angular CLI, you may find that uninstalling the package using `npm uninstall` is proving fruitless. This _could_ be because the global install (and uninstall) path changed between versions of npm from `/usr/local/lib` to `/usr/lib`, and hence, no longer searches through the old directory. In this case you'll have to remove it manually: - -`rm -rf /usr/local/lib/node_modules/@angular/cli` - -If the old Angular CLI package _still_ persists, you'll need to research how to remove it before proceeding with the upgrade. - -## .angular-cli.json - -`angular-cli.json` is now `.angular-cli.json`, but we still accept the old config file name. - -A few new properties have changed in it: - -### Schema - -Add the `$schema` property above project for handy IDE support on your config file: - -``` -"$schema": "./node_modules/@angular/cli/lib/config/schema.json", -``` - -### Polyfills - -There is now a dedicated entry for polyfills ([#3812](https://github.com/angular/angular-cli/pull/3812)) -inside `apps[0].polyfills`, between `main` and `test`: - -``` -"main": "main.ts", -"polyfills": "polyfills.ts", -"test": "test.ts", -``` - -Add it and remove `import './polyfills.ts';` from `src/main.ts` and `src/test.ts`. - -We also added a lot of descriptive comments to the existing `src/polyfills.ts` file, explaining -which polyfills are needed for what browsers. -Be sure to check it out in a new project! - -### Environments - -A new `environmentSource` entry ([#4705](https://github.com/angular/angular-cli/pull/4705)) -replaces the previous source entry inside environments. - -To migrate angular-cli.json follow the example below: - -Before: -``` -"environments": { - "source": "environments/environment.ts", - "dev": "environments/environment.ts", - "prod": "environments/environment.prod.ts" -} -``` - -After: - -``` -"environmentSource": "environments/environment.ts", -"environments": { - "dev": "environments/environment.ts", - "prod": "environments/environment.prod.ts" -} -``` - -### Linting - -The CLI now uses the TSLint API ([#4248](https://github.com/angular/angular-cli/pull/4248)) -to lint several TS projects at once. - -There is a new `lint` entry in `.angular-cli.json` between `e2e` and `test` where all linting -targets are listed: - -``` -"e2e": { - "protractor": { - "config": "./protractor.conf.js" - } -}, -"lint": [ - { - "project": "src/tsconfig.app.json" - }, - { - "project": "src/tsconfig.spec.json" - }, - { - "project": "e2e/tsconfig.e2e.json" - } -], -"test": { - "karma": { - "config": "./karma.conf.js" - } -}, -``` - -### Generator defaults - -Now you can list generator defaults per generator ([#4389](https://github.com/angular/angular-cli/pull/4389)) -in `defaults`. - -Instead of: -``` -"defaults": { - "styleExt": "css", - "prefixInterfaces": false, - "inline": { - "style": false, - "template": false - }, - "spec": { - "class": false, - "component": true, - "directive": true, - "module": false, - "pipe": true, - "service": true - } -} -``` - -You can instead list the flags as they appear on [the generator command](https://github.com/angular/angular-cli/wiki/generate-component): -``` -"defaults": { - "styleExt": "css", - "component": { - "inlineTemplate": false, - "spec": true - } -} -``` - -## One tsconfig per app - -CLI projects now use one tsconfig per app ([#4924](https://github.com/angular/angular-cli/pull/4924)). - -- `src/tsconfig.app.json`: configuration for the Angular app. -``` -{ - "compilerOptions": { - "sourceMap": true, - "declaration": false, - "moduleResolution": "node", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "target": "es5", - "lib": [ - "es2017", - "dom" - ], - "outDir": "../out-tsc/app", - "module": "es2015", - "baseUrl": "", - "types": [] - }, - "exclude": [ - "test.ts", - "**/*.spec.ts" - ] -} -``` -- `src/tsconfig.spec.json`: configuration for the unit tests. -``` -{ - "compilerOptions": { - "sourceMap": true, - "declaration": false, - "moduleResolution": "node", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "lib": [ - "es2017", - "dom" - ], - "outDir": "../out-tsc/spec", - "module": "commonjs", - "target": "es5", - "baseUrl": "", - "types": [ - "jasmine", - "node" - ] - }, - "files": [ - "test.ts" - ], - "include": [ - "**/*.spec.ts", - "**/*.d.ts" - ] -} - -``` -- `e2e/tsconfig.e2e.json`: configuration for the e2e tests. -``` -{ - "compilerOptions": { - "sourceMap": true, - "declaration": false, - "moduleResolution": "node", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "lib": [ - "es2017" - ], - "outDir": "../out-tsc/e2e", - "module": "commonjs", - "target": "es5", - "types":[ - "jasmine", - "node" - ] - } -} - -``` - -There is an additional root-level `tsconfig.json` that is used for IDE integration. -``` -{ - "compileOnSave": false, - "compilerOptions": { - "outDir": "./dist/out-tsc", - "baseUrl": "src", - "sourceMap": true, - "declaration": false, - "moduleResolution": "node", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "target": "es5", - "typeRoots": [ - "node_modules/@types" - ], - "lib": [ - "es2017", - "dom" - ] - } -} -``` - -You can delete `e2e/tsconfig.json` and `src/tsconfig.json` after adding these. - -Also update `.angular-cli.json` to use them inside `apps[0]`: - -``` -"tsconfig": "tsconfig.app.json", -"testTsconfig": "tsconfig.spec.json", -``` - -Then update `protractor.conf.js` to use the e2e config as well: -``` -beforeLaunch: function() { - require('ts-node').register({ - project: 'e2e/tsconfig.e2e.json' - }); -}, -``` - -These configs have an `types` array where you should add any package you install via `@types/*`. -This array helps keep typings isolated to the apps that really need them and avoid problems with -duplicate typings. - -For instance, the unit test `tsconfig` has `jasmine` and `node`, which correspond to -`@types/jasmine` and `@types/node`. -Add any typings you've installed to the appropriate `tsconfig` as well. - -## typings.d.ts - -There's a new `src/typings.d.ts` file that serves two purposes: -- provides a centralized place for users to add their own custom typings -- makes it easy to use components that use `module.id`, present in the documentation and in snippets - -``` -/* SystemJS module definition */ -declare var module: NodeModule; -interface NodeModule { - id: string; -} -``` - -## package.json - -We've updated a lot of packages over the last months in order to keep projects up to date. - -Additions or removals are found in bold below. - -Packages in `dependencies`: -- `@angular/*` packages now have a `^4.0.0` minimum -- `core-js` remains unchanged at `^2.4.1` -- `rxjs` was updated to `^5.1.0` -- `ts-helpers` was **removed** -- `zone.js` was updated to `^0.8.4` - -Packages in `devDependencies`: -- `@angular/cli` at `1.0.0` replaces `angular-cli` -- `@angular/compiler-cli` is also at `^4.0.0` -- `@types/jasmine` remains unchanged and pinned at `2.5.38` -- `@types/node` was updated to `~6.0.60` -- `codelyzer` was updated to `~2.0.0` -- `jasmine-core` was updated to `~2.5.2` -- `jasmine-spec-reporter` was updated to `~3.2.0` -- `karma` was updated to `~1.4.1` -- `karma-chrome-launcher` was updated to `~2.0.0` -- `karma-cli` was updated to `~1.0.1` -- `karma-jasmine` was updated to `~1.1.0` -- `karma-jasmine-html-reporter` was **added** at `^0.2.2` -- `karma-coverage-istanbul-reporter` was **added** at `^0.2.0`, replacing `karma-remap-istanbul` -- `karma-remap-istanbul` was **removed** -- `protractor` was updated to `~5.1.0` -- `ts-node` was updated to `~2.0.0` -- `tslint` was updated to `~4.5.0` -- `typescript` was updated to `~2.1.0` - -See the [karma](1-x/#karma.conf.js) and [protractor](1-x/#protractor.conf.js) sections below for more -information on changed packages. - -The [Linting rules](1-x/#Linting rules) section contains a list of rules that changed due to updates. - -We also updated the scripts section to make it more simple: - -``` -"scripts": { - "ng": "ng", - "start": "ng serve", - "build": "ng build", - "test": "ng test", - "lint": "ng lint", - "e2e": "ng e2e" -}, -``` - -## karma.conf.js - -Karma configuration suffered some changes to improve the code-coverage functionality, -use the new `@angular/cli` package, and the new HTML reporter. - -In the `frameworks` array update the CLI package to `@angular/cli`. - -In the `plugins` array: -- add `require('karma-jasmine-html-reporter')` and `require('karma-coverage-istanbul-reporter')` -- remove `require('karma-remap-istanbul')` -- update the CLI plugin to `require('@angular/cli/plugins/karma')` - -Add a new `client` option just above `patterns`: -``` -client:{ - clearContext: false // leave Jasmine Spec Runner output visible in browser -}, -files: [ -``` - -Change the preprocessor to use the new CLI package: -``` -preprocessors: { - './src/test.ts': ['@angular/cli'] -}, -``` - -Replace `remapIstanbulReporter` with `coverageIstanbulReporter`: -``` -coverageIstanbulReporter: { - reports: [ 'html', 'lcovonly' ], - fixWebpackSourcePaths: true -}, -``` - -Remove the config entry from `angularCli`: -``` -angularCli: { - environment: 'dev' -}, -``` - -Update the reporters to use `coverage-istanbul` instead of `karma-remap-istanbul`, and -add `kjhtml` (short for karma-jasmine-html-reporter): -``` -reporters: config.angularCli && config.angularCli.codeCoverage - ? ['progress', 'coverage-istanbul'] - : ['progress', 'kjhtml'], -``` - -## protractor.conf.js - -Protractor was updated to the new 5.x major version, but you shouldn't need to change much -to take advantage of all its new features. - -Replace the spec reporter import from: -``` -var SpecReporter = require('jasmine-spec-reporter'); -``` -to -``` -const { SpecReporter } = require('jasmine-spec-reporter'); -``` - -Remove `useAllAngular2AppRoots: true`. - -Update `beforeLaunch` as described in [One tsconfig per app](1-x/#one-tsconfig-per-app): -``` -beforeLaunch: function() { - require('ts-node').register({ - project: 'e2e/tsconfig.e2e.json' - }); -}, -``` - -Update `onPrepare`: -``` -onPrepare: function() { - jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); -} -``` - -## Linting rules - -The updated versions of `tslint` and `codelyzer` contain a few rule changes that you should -apply to your `tslint.json`: - -Add these new rules: -``` -"callable-types": true, -"import-blacklist": [true, "rxjs"], -"import-spacing": true, -"interface-over-type-literal": true, -"no-empty-interface": true, -"no-string-throw": true, -"prefer-const": true, -"typeof-compare": true, -"unified-signatures": true, -``` - -Update `no-inferrable-types` to `"no-inferrable-types": [true, "ignore-params"]`. diff --git a/docs/documentation/1-x/stories/application-environments.md b/docs/documentation/1-x/stories/application-environments.md deleted file mode 100644 index d3c4b18d054c..000000000000 --- a/docs/documentation/1-x/stories/application-environments.md +++ /dev/null @@ -1,122 +0,0 @@ -# Application Environments - -## Configuring available environments - -`.angular-cli.json` contains an **environments** section. By default, this looks like: - -``` json -"environments": { - "dev": "environments/environment.ts", - "prod": "environments/environment.prod.ts" -} -``` - -You can add additional environments as required. To add a **staging** environment, your configuration would look like: - -``` json -"environments": { - "dev": "environments/environment.ts", - "staging": "environments/environment.staging.ts", - "prod": "environments/environment.prod.ts" -} -``` - -## Adding environment-specific files - -The environment-specific files are set out as shown below: - -``` -└── src - └── environments - ├── environment.prod.ts - └── environment.ts -``` - -If you wanted to add another environment for **staging**, your file structure would become: - -``` -└── src - └── environments - ├── environment.prod.ts - ├── environment.staging.ts - └── environment.ts -``` - -## Amending environment-specific files - -`environment.ts` contains the default settings. If you take a look at this file, it should look like: - -``` TypeScript -export const environment = { - production: false -}; -``` - -If you compare this to `environment.prod.ts`, which looks like: - -``` TypeScript -export const environment = { - production: true -}; -``` - -You can add further variables, either as additional properties on the `environment` object, or as separate objects, for example: - -``` TypeScript -export const environment = { - production: false, - apiUrl: 'http://my-api-url' -}; -``` - -## Using environment-specific variables in your application - -Given the following application structure: - -``` -└── src - └── app - ├── app.component.html - └── app.component.ts - └── environments - ├── environment.prod.ts - ├── environment.staging.ts - └── environment.ts -``` - -Using environment variables inside of `app.component.ts` might look something like this: - -``` TypeScript -import { Component } from '@angular/core'; -import { environment } from './../environments/environment'; - -@Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.css'] -}) -export class AppComponent { - constructor() { - console.log(environment.production); // Logs false for default environment - } - title = 'app works!'; -} -``` - -## Environment-specific builds - -Running: - -``` -ng build -``` - -Will use the defaults found in `environment.ts` - -Running: - -``` -ng build --env=staging -``` - -Will use the values from `environment.staging.ts` diff --git a/docs/documentation/1-x/stories/asset-configuration.md b/docs/documentation/1-x/stories/asset-configuration.md deleted file mode 100644 index ef910b1d8561..000000000000 --- a/docs/documentation/1-x/stories/asset-configuration.md +++ /dev/null @@ -1,62 +0,0 @@ -# Project assets - -You use the `assets` array in `.angular-cli.json` to list files or folders you want to copy as-is -when building your project. - -By default, the `src/assets/` folder and `src/favicon.ico` are copied over. - -```json -"assets": [ - "assets", - "favicon.ico" -] -``` - -You can also further configure assets to be copied by using objects as configuration. - -The array below does the same as the default one: - -```json -"assets": [ - { "glob": "**/*", "input": "./assets/", "output": "./assets/" }, - { "glob": "favicon.ico", "input": "./", "output": "./" }, -] -``` - -`glob` is the a [node-glob](https://github.com/isaacs/node-glob) using `input` as base directory. -`input` is relative to the project root (`src/` default), while `output` is - relative to `outDir` (`dist` default). - - You can use this extended configuration to copy assets from outside your project. - For instance, you can copy assets from a node package: - - ```json -"assets": [ - { "glob": "**/*", "input": "../node_modules/some-package/images", "output": "./some-package/" }, -] -``` - -The contents of `node_modules/some-package/images/` will be available in `dist/some-package/`. - -## Writing assets outside of `dist/` - -Because of the security implications, the CLI will always refuse to read or write files outside of -the project itself (scoped by `.angular-cli.json`). It is however possible to write assets outside -the `dist/` build output folder during build. - -Because writing files in your project isn't an expected effect of `ng build`, it is disabled by -default on every assets. In order to allow this behaviour, you need to set `allowOutsideOutDir` -to `true` on your asset definition, like so: - -```json -"assets": [ - { - "glob": "**/*", - "input": "./assets/", - "output": "../not-dist/some/folder/", - "allowOutsideOutDir": true - }, -] -``` - -This needs to be set for every assets you want to write outside of your build output directory. diff --git a/docs/documentation/1-x/stories/autocompletion.md b/docs/documentation/1-x/stories/autocompletion.md deleted file mode 100644 index 324d6a2faa58..000000000000 --- a/docs/documentation/1-x/stories/autocompletion.md +++ /dev/null @@ -1,21 +0,0 @@ -# Autocompletion - -To turn on auto completion use the following commands: - -For bash: -```bash -ng completion --bash >> ~/.bashrc -source ~/.bashrc -``` - -For zsh: -```bash -ng completion --zsh >> ~/.zshrc -source ~/.zshrc -``` - -Windows users using gitbash: -```bash -ng completion --bash >> ~/.bash_profile -source ~/.bash_profile -``` \ No newline at end of file diff --git a/docs/documentation/1-x/stories/autoprefixer.md b/docs/documentation/1-x/stories/autoprefixer.md deleted file mode 100644 index c6902fccaf3b..000000000000 --- a/docs/documentation/1-x/stories/autoprefixer.md +++ /dev/null @@ -1,43 +0,0 @@ -# Change target browsers for Autoprefixer - -Currently, the CLI uses [Autoprefixer](https://github.com/postcss/autoprefixer) to ensure compatibility -with different browser and browser versions. You may find it necessary to target specific browsers -or exclude certain browser versions from your build. - -Internally, Autoprefixer relies on a library called [Browserslist](https://github.com/browserslist/browserslist) -to figure out which browsers to support with prefixing. - -There are a few ways to tell Autoprefixer what browsers to target: - -### Add a browserslist property to the `package.json` file -``` -"browserslist": [ - "> 1%", - "last 2 versions" -] -``` - -### Add a new file to the project directory called `.browserslistrc` -``` -### Supported Browsers - -> 1% -last 2 versions -``` - -Autoprefixer will look for the configuration file/property to use when it prefixes your css. -Check out the [browserslist repo](https://github.com/browserslist/browserslist) for more examples of how to target -specific browsers and versions. - -_Side note:_ -Those who are seeking to produce a [progressive web app](https://developers.google.com/web/progressive-web-apps/) and are using [Lighthouse](https://developers.google.com/web/tools/lighthouse/) to grade the project will -need to add the following browserslist config to their package.json file to eliminate the [old flexbox](https://developers.google.com/web/tools/lighthouse/audits/old-flexbox) prefixes: - -`package.json` config: -``` -"browserslist": [ - "last 2 versions", - "not ie <= 10", - "not ie_mob <= 10" -] -``` diff --git a/docs/documentation/1-x/stories/budgets.md b/docs/documentation/1-x/stories/budgets.md deleted file mode 100644 index e01364f9ce09..000000000000 --- a/docs/documentation/1-x/stories/budgets.md +++ /dev/null @@ -1,62 +0,0 @@ -# Budgets - -As applications grow in functionality, they also grow in size. Budgets is a feature in the -Angular CLI which allows you to set budget thresholds in your configuration to ensure parts -of your application stay within boundries which you set. - -**.angular-cli.json** -``` -{ - ... - apps: [ - { - ... - budgets: [] - } - ] -} -``` - -## Budget Definition - -- type - - The type of budget. - - Possible values: - - bundle - The size of a specific bundle. - - initial - The initial size of the app. - - allScript - The size of all scripts. - - all - The size of the entire app. - - anyScript - The size of any one script. - - any - The size of any file. -- name - - The name of the bundle. - - Required only for type of "bundle" -- baseline - - The baseline size for comparison. -- maximumWarning - - The maximum threshold for warning relative to the baseline. -- maximumError - - The maximum threshold for error relative to the baseline. -- minimumWarning - - The minimum threshold for warning relative to the baseline. -- minimumError - - The minimum threshold for error relative to the baseline. -- warning - - The threshold for warning relative to the baseline (min & max). -- error - - The threshold for error relative to the baseline (min & max). - -## Specifying sizes - -Available formats: - -- `123` - size in bytes -- `123b` - size in bytes -- `123kb` - size in kilobytes -- `123mb` - size in megabytes -- `12%` - percentage - -## NOTES - -All sizes are relative to baseline. -Percentages are not valid for baseline values. diff --git a/docs/documentation/1-x/stories/code-coverage.md b/docs/documentation/1-x/stories/code-coverage.md deleted file mode 100644 index 354063cc1242..000000000000 --- a/docs/documentation/1-x/stories/code-coverage.md +++ /dev/null @@ -1,32 +0,0 @@ -# Code Coverage - -With the Angular CLI we can run unit tests as well as create code coverage reports. Code coverage reports allow us to see any parts of our code base that may not be properly tested by our unit tests. - -To generate a coverage report run the following command in the root of your project - -```bash -ng test --watch=false --code-coverage -``` - -Once the tests complete a new `/coverage` folder will appear in the project. In your Finder or Windows Explorer open the `index.html` file. You should see a report with your source code and code coverage values. - -Using the code coverage percentages we can estimate how much of our code is tested. It is up to your team to determine how much code should be unit tested. - -## Code Coverage Enforcement - -If your team decides on a set minimum amount to be unit tested you can enforce this minimum with the Angular CLI. For example our team would like the code base to have a minimum of 80% code coverage. To enable this open the `karma.conf.js` and add the following in the `coverageIstanbulReporter:` key - -```javascript -coverageIstanbulReporter: { - reports: [ 'html', 'lcovonly' ], - fixWebpackSourcePaths: true, - thresholds: { - statements: 80, - lines: 80, - branches: 80, - functions: 80 - } -} -``` - -The `thresholds` property will enforce a minimum of 80% code coverage when the unit tests are run in the project. \ No newline at end of file diff --git a/docs/documentation/1-x/stories/configure-hmr.md b/docs/documentation/1-x/stories/configure-hmr.md deleted file mode 100644 index 1823e8f1ba79..000000000000 --- a/docs/documentation/1-x/stories/configure-hmr.md +++ /dev/null @@ -1,150 +0,0 @@ -# Configure Hot Module Replacement - -Hot Module Replacement (HMR) is a WebPack feature to update code in a running app without rebuilding it. -This results in faster updates and less full page-reloads. - -You can read more about HMR by visiting [this page](https://webpack.js.org/guides/hot-module-replacement/). - -In order to get HMR working with Angular CLI we first need to add a new environment and enable it. - -Next we need to update the bootstrap process of our app to enable the -[@angularclass/hmr](https://github.com/gdi2290/angular-hmr) module. - -### Add environment for HMR - -Create a file called `src/environments/environment.hmr.ts` with the following contents: - -```typescript - -export const environment = { - production: false, - hmr: true -}; -``` - -Update `src/environments/environment.prod.ts` and add the `hmr: false` flag to the environment: - -```typescript -export const environment = { - production: true, - hmr: false -}; -``` - -Update `src/environments/environment.ts` and add the `hmr: false` flag to the environment: - -```typescript -export const environment = { - production: false, - hmr: false -}; -``` - - -Update `.angular-cli.json` by adding the new environment the existing environments object: - -```json -"environmentSource": "environments/environment.ts", -"environments": { - "dev": "environments/environment.ts", - "hmr": "environments/environment.hmr.ts", - "prod": "environments/environment.prod.ts" -}, -``` - -Run `ng serve` with the flag `--hmr -e=hmr` to enable hmr and select the new environment: - -```bash -ng serve --hmr -e=hmr -``` - -Create a shortcut for this by updating `package.json` and adding an entry to the script object: - -```json -"scripts": { - ... - "hmr": "ng serve --hmr -e=hmr" -} -``` - - -### Add dependency for @angularclass/hmr and configure app - -In order to get HMR working we need to install the dependency and configure our app to use it. - - -Install the `@angularclass/hmr` module as a dev-dependency - -```bash -$ npm install --save-dev @angularclass/hmr -``` - - -Create a file called `src/hmr.ts` with the following content: - -```typescript -import { NgModuleRef, ApplicationRef } from '@angular/core'; -import { createNewHosts } from '@angularclass/hmr'; - -export const hmrBootstrap = (module: any, bootstrap: () => Promise>) => { - let ngModule: NgModuleRef; - module.hot.accept(); - bootstrap().then(mod => ngModule = mod); - module.hot.dispose(() => { - const appRef: ApplicationRef = ngModule.injector.get(ApplicationRef); - const elements = appRef.components.map(c => c.location.nativeElement); - const makeVisible = createNewHosts(elements); - ngModule.destroy(); - makeVisible(); - }); -}; -``` - - -Update `src/main.ts` to use the file we just created: - -```typescript -import { enableProdMode } from '@angular/core'; -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; - -import { AppModule } from './app/app.module'; -import { environment } from './environments/environment'; - -import { hmrBootstrap } from './hmr'; - -if (environment.production) { - enableProdMode(); -} - -const bootstrap = () => platformBrowserDynamic().bootstrapModule(AppModule); - -if (environment.hmr) { - if (module[ 'hot' ]) { - hmrBootstrap(module, bootstrap); - } else { - console.error('HMR is not enabled for webpack-dev-server!'); - console.log('Are you using the --hmr flag for ng serve?'); - } -} else { - bootstrap(); -} -``` - - -### Starting the development environment with HMR enabled - -Now that everything is set up we can run the new configuration: - -```bash -$ npm run hmr -``` - -When starting the server Webpack will tell you that it’s enabled: - - - NOTICE Hot Module Replacement (HMR) is enabled for the dev server. - - -Now if you make changes to one of your components they changes should be visible automatically without a complete browser refresh. - - diff --git a/docs/documentation/1-x/stories/continuous-integration.md b/docs/documentation/1-x/stories/continuous-integration.md deleted file mode 100644 index 7c079084114b..000000000000 --- a/docs/documentation/1-x/stories/continuous-integration.md +++ /dev/null @@ -1,155 +0,0 @@ -# Continuous Integration - -One of the best ways to keep your project bug free is through a test suite, but it's easy to forget -to run tests all the time. - -That's where Continuous Integration (CI) servers come in. -You can set up your project repository so that your tests run on every commit and pull request. - -There are paid CI services like [Circle CI](https://circleci.com/) and -[Travis CI](https://travis-ci.com/), and you can also host your own for free using -[Jenkins](https://jenkins.io/) and others. - -Even though Circle CI and Travis CI are paid services, they are provided free for open source -projects. -You can create a public project on GitHub and add these services without paying. - -We're going to see how to update your test configuration to run in CI environments, and how to -set up Circle CI and Travis CI. - - -## Update test configuration - -Even though `ng test` and `ng e2e` already run on your environment, they need to be adjusted to -run in CI environments. - -When using Chrome in CI environments it has to be started without sandboxing. -We can achieve that by editing our test configs. - -In `karma.conf.js`, add a custom launcher called `ChromeNoSandbox` below `browsers`: - -``` -browsers: ['Chrome'], -customLaunchers: { - ChromeNoSandbox: { - base: 'Chrome', - flags: ['--no-sandbox'] - } -}, -``` - -Create a new file in the root of your project called `protractor-ci.conf.js`, that extends -the original `protractor.conf.js`: - -``` -const config = require('./protractor.conf').config; - -config.capabilities = { - browserName: 'chrome', - chromeOptions: { - args: ['--no-sandbox'] - } -}; - -exports.config = config; -``` - -Now you can run the following commands to use the `--no-sandbox` flag: - -``` -ng test --single-run --no-progress --browser=ChromeNoSandbox -ng e2e --no-progress --config=protractor-ci.conf.js -``` - -For CI environments it's also a good idea to disable progress reporting (via `--no-progress`) -to avoid spamming the server log with progress messages. - - -## Using Circle CI - -Create a folder called `.circleci` at the project root, and inside of it create a file called -`config.yml`: - -```yaml -version: 2 -jobs: - build: - working_directory: ~/my-project - docker: - - image: circleci/node:8-browsers - steps: - - checkout - - restore_cache: - key: my-project-{{ .Branch }}-{{ checksum "package.json" }} - - run: npm install - - save_cache: - key: my-project-{{ .Branch }}-{{ checksum "package.json" }} - paths: - - "node_modules" - - run: xvfb-run -a npm run test -- --single-run --no-progress --browser=ChromeNoSandbox - - run: xvfb-run -a npm run e2e -- --no-progress --config=protractor-ci.conf.js - -``` - -We're doing a few things here: - - - - `node_modules` is cached. - - [npm run](https://docs.npmjs.com/cli/run-script) is used to run `ng` because `@angular/cli` is - not installed globally. The double dash (`--`) is needed to pass arguments into the npm script. - - `xvfb-run` is used to run `npm run` to run a command using a virtual screen, which is needed by - Chrome. - -Commit your changes and push them to your repository. - -Next you'll need to [sign up for Circle CI](https://circleci.com/docs/2.0/first-steps/) and -[add your project](https://circleci.com/add-projects). -Your project should start building. - -Be sure to check out the [Circle CI docs](https://circleci.com/docs/2.0/) if you want to know more. - - -## Using Travis CI - -Create a file called `.travis.yml` at the project root: - -```yaml -dist: trusty -sudo: false - -language: node_js -node_js: - - "8" - -addons: - apt: - sources: - - google-chrome - packages: - - google-chrome-stable - -cache: - directories: - - ./node_modules - -install: - - npm install - -script: - # Use Chromium instead of Chrome. - - export CHROME_BIN=chromium-browser - - xvfb-run -a npm run test -- --single-run --no-progress --browser=ChromeNoSandbox - - xvfb-run -a npm run e2e -- --no-progress --config=protractor-ci.conf.js - -``` - -Although the syntax is different, we're mostly doing the same steps as were done in the -Circle CI config. -The only difference is that Travis doesn't come with Chrome, so we use Chromium instead. - -Commit your changes and push them to your repository. - -Next you'll need to [sign up for Travis CI](https://travis-ci.org/auth) and -[add your project](https://travis-ci.org/profile). -You'll need to push a new commit to trigger a build. - -Be sure to check out the [Travis CI docs](https://docs.travis-ci.com/) if you want to know more. diff --git a/docs/documentation/1-x/stories/css-preprocessors.md b/docs/documentation/1-x/stories/css-preprocessors.md deleted file mode 100644 index 58bd0071deab..000000000000 --- a/docs/documentation/1-x/stories/css-preprocessors.md +++ /dev/null @@ -1,34 +0,0 @@ -# CSS Preprocessor integration - -Angular CLI supports all major CSS preprocessors: -- sass/scss ([http://sass-lang.com/](http://sass-lang.com/)) -- less ([http://lesscss.org/](http://lesscss.org/)) -- stylus ([http://stylus-lang.com/](http://stylus-lang.com/)) - -To use these preprocessors simply add the file to your component's `styleUrls`: - -```javascript -@Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.scss'] -}) -export class AppComponent { - title = 'app works!'; -} -``` - -When generating a new project you can also define which extension you want for -style files: - -```bash -ng new sassy-project --style=sass -``` - -Or set the default style on an existing project: - -```bash -ng set defaults.styleExt scss -``` - -Style strings added to the `@Component.styles` array _must be written in CSS_ because the CLI cannot apply a pre-processor to inline styles. \ No newline at end of file diff --git a/docs/documentation/1-x/stories/disk-serve.md b/docs/documentation/1-x/stories/disk-serve.md deleted file mode 100644 index b3f4663b630b..000000000000 --- a/docs/documentation/1-x/stories/disk-serve.md +++ /dev/null @@ -1,23 +0,0 @@ -# Serve from Disk - -The CLI supports running a live browser reload experience to users by running `ng serve`. This will compile the application upon file saves and reload the browser with the newly compiled application. This is done by hosting the application in memory and serving it via [webpack-dev-server](https://webpack.js.org/guides/development/#webpack-dev-server). - -If you wish to get a similar experience with the application output to disk please use the steps below. This practice will allow you to ensure that serving the contents of your `dist` dir will be closer to how your application will behave when it is deployed. - -## Environment Setup -### Install a web server -You will not be using webpack-dev-server, so you will need to install a web server for the browser to request the application. There are many to choose from but a good one to try is [lite-server](https://github.com/johnpapa/lite-server) as it will auto-reload your browser when new files are output. - -## Usage -You will need two terminals to get the live-reload experience. The first will run the build in a watch mode to compile the application to the `dist` folder. The second will run the web server against the `dist` folder. The combination of these two processes will mimic the same behavior of ng serve. - -### 1st terminal - Start the build -```bash -ng build --watch -``` - -### 2nd terminal - Start the web server -```bash -lite-server --baseDir="dist" -``` -When using `lite-server` the default browser will open to the appropriate URL. diff --git a/docs/documentation/1-x/stories/github-pages.md b/docs/documentation/1-x/stories/github-pages.md deleted file mode 100644 index 2bb0d0f28f7f..000000000000 --- a/docs/documentation/1-x/stories/github-pages.md +++ /dev/null @@ -1,21 +0,0 @@ -# Deploy to GitHub Pages - -A simple way to deploy your Angular app is to use -[GitHub Pages](https://help.github.com/articles/what-is-github-pages/). - -The first step is to [create a GitHub account](https://github.com/join), and then -[create a repository](https://help.github.com/articles/create-a-repo/) for your project. -Make a note of the user name and project name in GitHub. - -Then all you need to do is run `ng build --prod --output-path docs --base-href PROJECT_NAME`, where -`PROJECT_NAME` is the name of your project in GitHub. -Make a copy of `docs/index.html` and name it `docs/404.html`. - -Commit your changes and push. On the GitHub project page, configure it to -[publish from the docs folder](https://help.github.com/articles/configuring-a-publishing-source-for-github-pages/#publishing-your-github-pages-site-from-a-docs-folder-on-your-master-branch). - -And that's all you need to do! Now you can see your page at -`https://USER_NAME.github.io/PROJECT_NAME/`. - -You can also use [angular-cli-ghpages](https://github.com/angular-schule/angular-cli-ghpages), a full -featured package that does this all this for you and has extra functionality. diff --git a/docs/documentation/1-x/stories/global-lib.md b/docs/documentation/1-x/stories/global-lib.md deleted file mode 100644 index 91c621acfd5c..000000000000 --- a/docs/documentation/1-x/stories/global-lib.md +++ /dev/null @@ -1,37 +0,0 @@ -# Global Library Installation - -Some javascript libraries need to be added to the global scope, and loaded as if -they were in a script tag. We can do this using the `apps[0].scripts` and -`apps[0].styles` properties of `.angular-cli.json`. - -As an example, to use [Bootstrap 4](https://getbootstrap.com/docs/4.0/getting-started/introduction/) this is -what you need to do: - -First install Bootstrap from `npm`: - -```bash -npm install jquery --save -npm install popper.js --save -npm install bootstrap@next --save -``` - -Then add the needed script files to `apps[0].scripts`: - -```json -"scripts": [ - "../node_modules/jquery/dist/jquery.slim.js", - "../node_modules/popper.js/dist/umd/popper.js", - "../node_modules/bootstrap/dist/js/bootstrap.js" -], -``` - -Finally add the Bootstrap CSS to the `apps[0].styles` array: -```json -"styles": [ - "../node_modules/bootstrap/dist/css/bootstrap.css", - "styles.css" -], -``` - -Restart `ng serve` if you're running it, and Bootstrap 4 should be working on -your app. diff --git a/docs/documentation/1-x/stories/global-scripts.md b/docs/documentation/1-x/stories/global-scripts.md deleted file mode 100644 index fe12d7473e03..000000000000 --- a/docs/documentation/1-x/stories/global-scripts.md +++ /dev/null @@ -1,56 +0,0 @@ -# Global scripts - -You can add Javascript files to the global scope via the `apps[0].scripts` -property in `.angular-cli.json`. -These will be loaded exactly as if you had added them in a ` - - diff --git a/etc/cli.angular.io/license.html b/etc/cli.angular.io/license.html deleted file mode 100644 index 0131cc303c40..000000000000 --- a/etc/cli.angular.io/license.html +++ /dev/null @@ -1,23 +0,0 @@ -
The MIT License
-
-Copyright (c) Google, Inc. All Rights Reserved.
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-
- diff --git a/etc/cli.angular.io/main.css b/etc/cli.angular.io/main.css deleted file mode 100644 index 61b0cb066543..000000000000 --- a/etc/cli.angular.io/main.css +++ /dev/null @@ -1 +0,0 @@ -body{font-family:"Roboto",Helvetica,sans-serif}h4,h5{font-size:30px;font-weight:400;line-height:40px;margin-bottom:15px;margin-top:15px}h5{font-size:16px;font-weight:300;line-height:28px;margin-bottom:25px;max-width:300px}.mdl-demo section.section--center{max-width:920px}.mdl-grid--no-spacing>.mdl-cell{width:100%}.mdl-layout--fixed-drawer>.mdl-layout__content{margin-left:0}.mdl-layout__header{background-color:#f44336;box-shadow:0 2px 5px 0 rgba(0,0,0,.26)}.mdl-layout__header a{color:#fff;text-decoration:none}.mdl-layout--fixed-drawer.is-upgraded:not(.is-small-screen)>.mdl-layout__header{margin-left:0;width:100%}.mdl-layout--fixed-drawer>.mdl-layout__header .mdl-layout__header-row{padding-left:25px;padding-right:0}.mdl-layout__drawer-button,.top-nav-wrapper label{display:none}@media (max-width:1024px){.mdl-layout__drawer-button{display:inline-block}}.mdl-layout__drawer{margin-top:65px;height:calc(100% - 65px)}@media (max-width:1024px){.mdl-layout__drawer{margin-top:0;height:100%}}.mdl-layout-title,.mdl-layout__title{font-size:16px;line-height:28px;letter-spacing:.02em}.microsite-name{display:inline-block;font-size:20px;margin-left:8px;margin-right:30px;text-transform:uppercase;-webkit-transform:translateY(3px);transform:translateY(3px)}.mdl-navigation__link{font-size:16px;text-transform:uppercase;text-decoration:none}.mdl-navigation__link:hover,.top-nav-wrapper label:hover{background-color:#d32f2f}.top-nav-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1}@media (max-width:800px){.top-nav-wrapper{display:block;position:absolute;right:0;top:0;width:100%}.top-nav-wrapper label{cursor:pointer;display:block;float:right;line-height:56px;padding:0 16px}.top-nav-wrapper nav{background:#d32f2f;clear:both;display:none;height:auto!important}.top-nav-wrapper nav a{display:block}.top-nav-wrapper .mdl-layout-spacer{display:none}input:checked+.top-nav-wrapper label{background:#d32f2f}input:checked+.top-nav-wrapper nav{display:block}}.hero-background{background:-webkit-linear-gradient(#d32f2f ,#f44336);background:linear-gradient(#d32f2f ,#f44336);color:#fff;margin-bottom:60px}.mdl-grid,.mdl-mega-footer--bottom-section .mdl-cell--9-col{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.hero-container{padding:56px 0!important}@media (max-width:830px){.hero-container{text-align:center}}.logo-container{overflow:hidden;text-align:center}@media (max-width:840px){.tagline{max-width:100%}}.mdl-button{height:45px;line-height:45px;min-width:140px;padding:0 30px}.mdl-button--primary.mdl-button--primary.mdl-button--fab,.mdl-button--primary.mdl-button--primary.mdl-button--raised{background-color:#fff;color:#b71c1c}.features-list{width:920px;margin:0 0 23px;padding:15px 200px 15px 15px}@media (max-width:840px){.features-list{padding-right:15px}}.features-list h4{color:#37474f;font-size:28px;font-weight:500;line-height:32px;margin:0 0 16px;opacity:.87}.features-list p,footer ul a{font-size:16px;line-height:30px;opacity:.87}.button-container{margin-bottom:24px!important;text-align:center}.mdl-button--accent.mdl-button--accent.mdl-button--fab,.mdl-button--accent.mdl-button--accent.mdl-button--raised{background-color:#f44336;color:#fff}.mdl-mega-footer--bottom-section .mdl-cell--9-col{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end;display:-webkit-box;display:-ms-flexbox;display:flex}.mdl-mega-footer--bottom-section,.mdl-mega-footer__bottom-section{background-color:#263238;bottom:0;color:#fff;padding-top:0;right:0}footer ul{font-size:14px;font-weight:400;letter-spacing:0;line-height:24px;list-style:none;padding:0}footer ul a{color:#fff;line-height:28px;padding:0;text-decoration:none}footer ul a:hover{text-decoration:underline}@media (max-width:830px){footer ul{background-color:rgba(0,0,0,.12);padding:8px;text-align:center}}.mdl-mega-footer--bottom-section{margin-bottom:0}.mdl-mega-footer--bottom-section p{font-size:12px;margin:0;opacity:.54}.mdl-mega-footer--bottom-section a{color:#fff;font-weight:400;padding:0;text-decoration:none}.power-text{text-align:right}@media (max-width:830px){.power-text{text-align:center;width:calc(100% - 16px)}}.mdl-base{height:100vh} diff --git a/etc/cli.angular.io/material.min.css b/etc/cli.angular.io/material.min.css deleted file mode 100644 index e750f8137205..000000000000 --- a/etc/cli.angular.io/material.min.css +++ /dev/null @@ -1,9 +0,0 @@ -/** - * material-design-lite - Material Design Components in CSS, JS and HTML - * @version v1.1.3 - * @license Apache-2.0 - * @copyright 2015 Google, Inc. - * @link https://github.com/google/material-design-lite - */ -@charset "UTF-8";html{color:rgba(0,0,0,.87)}::-moz-selection{background:#b3d4fc;text-shadow:none}::selection{background:#b3d4fc;text-shadow:none}hr{display:block;height:1px;border:0;border-top:1px solid #ccc;margin:1em 0;padding:0}audio,canvas,iframe,img,svg,video{vertical-align:middle}fieldset{border:0;margin:0;padding:0}textarea{resize:vertical}.browserupgrade{margin:.2em 0;background:#ccc;color:#000;padding:.2em 0}.hidden{display:none!important}.visuallyhidden{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.visuallyhidden.focusable:active,.visuallyhidden.focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}.invisible{visibility:hidden}.clearfix:before,.clearfix:after{content:" ";display:table}.clearfix:after{clear:both}@media print{*,*:before,*:after,*:first-letter{background:transparent!important;color:#000!important;box-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href)")"}abbr[title]:after{content:" (" attr(title)")"}a[href^="#"]:after,a[href^="javascript:"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100%!important}p,h2,h3{orphans:3;widows:3}h2,h3{page-break-after:avoid}}a,.mdl-accordion,.mdl-button,.mdl-card,.mdl-checkbox,.mdl-dropdown-menu,.mdl-icon-toggle,.mdl-item,.mdl-radio,.mdl-slider,.mdl-switch,.mdl-tabs__tab{-webkit-tap-highlight-color:transparent;-webkit-tap-highlight-color:rgba(255,255,255,0)}html{width:100%;height:100%;-ms-touch-action:manipulation;touch-action:manipulation}body{width:100%;min-height:100%;margin:0}main{display:block}*[hidden]{display:none!important}html,body{font-family:"Helvetica","Arial",sans-serif;font-size:14px;font-weight:400;line-height:20px}h1,h2,h3,h4,h5,h6,p{padding:0}h1 small,h2 small,h3 small,h4 small,h5 small,h6 small{font-family:"Roboto","Helvetica","Arial",sans-serif;font-weight:400;line-height:1.35;letter-spacing:-.02em;opacity:.54;font-size:.6em}h1{font-size:56px;line-height:1.35;letter-spacing:-.02em;margin:24px 0}h1,h2{font-family:"Roboto","Helvetica","Arial",sans-serif;font-weight:400}h2{font-size:45px;line-height:48px}h2,h3{margin:24px 0}h3{font-size:34px;line-height:40px}h3,h4{font-family:"Roboto","Helvetica","Arial",sans-serif;font-weight:400}h4{font-size:24px;line-height:32px;-moz-osx-font-smoothing:grayscale;margin:24px 0 16px}h5{font-size:20px;font-weight:500;line-height:1;letter-spacing:.02em}h5,h6{font-family:"Roboto","Helvetica","Arial",sans-serif;margin:24px 0 16px}h6{font-size:16px;letter-spacing:.04em}h6,p{font-weight:400;line-height:24px}p{font-size:14px;letter-spacing:0;margin:0 0 16px}a{color:#ff4081;font-weight:500}blockquote{font-family:"Roboto","Helvetica","Arial",sans-serif;position:relative;font-size:24px;font-weight:300;font-style:italic;line-height:1.35;letter-spacing:.08em}blockquote:before{position:absolute;left:-.5em;content:'“'}blockquote:after{content:'”';margin-left:-.05em}mark{background-color:#f4ff81}dt{font-weight:700}address{font-size:12px;line-height:1;font-style:normal}address,ul,ol{font-weight:400;letter-spacing:0}ul,ol{font-size:14px;line-height:24px}.mdl-typography--display-4,.mdl-typography--display-4-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:112px;font-weight:300;line-height:1;letter-spacing:-.04em}.mdl-typography--display-4-color-contrast{opacity:.54}.mdl-typography--display-3,.mdl-typography--display-3-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:56px;font-weight:400;line-height:1.35;letter-spacing:-.02em}.mdl-typography--display-3-color-contrast{opacity:.54}.mdl-typography--display-2,.mdl-typography--display-2-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:45px;font-weight:400;line-height:48px}.mdl-typography--display-2-color-contrast{opacity:.54}.mdl-typography--display-1,.mdl-typography--display-1-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:34px;font-weight:400;line-height:40px}.mdl-typography--display-1-color-contrast{opacity:.54}.mdl-typography--headline,.mdl-typography--headline-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:24px;font-weight:400;line-height:32px;-moz-osx-font-smoothing:grayscale}.mdl-typography--headline-color-contrast{opacity:.87}.mdl-typography--title,.mdl-typography--title-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:20px;font-weight:500;line-height:1;letter-spacing:.02em}.mdl-typography--title-color-contrast{opacity:.87}.mdl-typography--subhead,.mdl-typography--subhead-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:16px;font-weight:400;line-height:24px;letter-spacing:.04em}.mdl-typography--subhead-color-contrast{opacity:.87}.mdl-typography--body-2,.mdl-typography--body-2-color-contrast{font-size:14px;font-weight:700;line-height:24px;letter-spacing:0}.mdl-typography--body-2-color-contrast{opacity:.87}.mdl-typography--body-1,.mdl-typography--body-1-color-contrast{font-size:14px;font-weight:400;line-height:24px;letter-spacing:0}.mdl-typography--body-1-color-contrast{opacity:.87}.mdl-typography--body-2-force-preferred-font,.mdl-typography--body-2-force-preferred-font-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:14px;font-weight:500;line-height:24px;letter-spacing:0}.mdl-typography--body-2-force-preferred-font-color-contrast{opacity:.87}.mdl-typography--body-1-force-preferred-font,.mdl-typography--body-1-force-preferred-font-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:14px;font-weight:400;line-height:24px;letter-spacing:0}.mdl-typography--body-1-force-preferred-font-color-contrast{opacity:.87}.mdl-typography--caption,.mdl-typography--caption-force-preferred-font{font-size:12px;font-weight:400;line-height:1;letter-spacing:0}.mdl-typography--caption-force-preferred-font{font-family:"Roboto","Helvetica","Arial",sans-serif}.mdl-typography--caption-color-contrast,.mdl-typography--caption-force-preferred-font-color-contrast{font-size:12px;font-weight:400;line-height:1;letter-spacing:0;opacity:.54}.mdl-typography--caption-force-preferred-font-color-contrast,.mdl-typography--menu{font-family:"Roboto","Helvetica","Arial",sans-serif}.mdl-typography--menu{font-size:14px;font-weight:500;line-height:1;letter-spacing:0}.mdl-typography--menu-color-contrast{opacity:.87}.mdl-typography--menu-color-contrast,.mdl-typography--button,.mdl-typography--button-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:14px;font-weight:500;line-height:1;letter-spacing:0}.mdl-typography--button,.mdl-typography--button-color-contrast{text-transform:uppercase}.mdl-typography--button-color-contrast{opacity:.87}.mdl-typography--text-left{text-align:left}.mdl-typography--text-right{text-align:right}.mdl-typography--text-center{text-align:center}.mdl-typography--text-justify{text-align:justify}.mdl-typography--text-nowrap{white-space:nowrap}.mdl-typography--text-lowercase{text-transform:lowercase}.mdl-typography--text-uppercase{text-transform:uppercase}.mdl-typography--text-capitalize{text-transform:capitalize}.mdl-typography--font-thin{font-weight:200!important}.mdl-typography--font-light{font-weight:300!important}.mdl-typography--font-regular{font-weight:400!important}.mdl-typography--font-medium{font-weight:500!important}.mdl-typography--font-bold{font-weight:700!important}.mdl-typography--font-black{font-weight:900!important}.material-icons{font-family:'Material Icons';font-weight:400;font-style:normal;font-size:24px;line-height:1;letter-spacing:normal;text-transform:none;display:inline-block;word-wrap:normal;-moz-font-feature-settings:'liga';font-feature-settings:'liga';-webkit-font-feature-settings:'liga';-webkit-font-smoothing:antialiased}.mdl-color-text--red{color:#f44336 !important}.mdl-color--red{background-color:#f44336 !important}.mdl-color-text--red-50{color:#ffebee !important}.mdl-color--red-50{background-color:#ffebee !important}.mdl-color-text--red-100{color:#ffcdd2 !important}.mdl-color--red-100{background-color:#ffcdd2 !important}.mdl-color-text--red-200{color:#ef9a9a !important}.mdl-color--red-200{background-color:#ef9a9a !important}.mdl-color-text--red-300{color:#e57373 !important}.mdl-color--red-300{background-color:#e57373 !important}.mdl-color-text--red-400{color:#ef5350 !important}.mdl-color--red-400{background-color:#ef5350 !important}.mdl-color-text--red-500{color:#f44336 !important}.mdl-color--red-500{background-color:#f44336 !important}.mdl-color-text--red-600{color:#e53935 !important}.mdl-color--red-600{background-color:#e53935 !important}.mdl-color-text--red-700{color:#d32f2f !important}.mdl-color--red-700{background-color:#d32f2f !important}.mdl-color-text--red-800{color:#c62828 !important}.mdl-color--red-800{background-color:#c62828 !important}.mdl-color-text--red-900{color:#b71c1c !important}.mdl-color--red-900{background-color:#b71c1c !important}.mdl-color-text--red-A100{color:#ff8a80 !important}.mdl-color--red-A100{background-color:#ff8a80 !important}.mdl-color-text--red-A200{color:#ff5252 !important}.mdl-color--red-A200{background-color:#ff5252 !important}.mdl-color-text--red-A400{color:#ff1744 !important}.mdl-color--red-A400{background-color:#ff1744 !important}.mdl-color-text--red-A700{color:#d50000 !important}.mdl-color--red-A700{background-color:#d50000 !important}.mdl-color-text--pink{color:#e91e63 !important}.mdl-color--pink{background-color:#e91e63 !important}.mdl-color-text--pink-50{color:#fce4ec !important}.mdl-color--pink-50{background-color:#fce4ec !important}.mdl-color-text--pink-100{color:#f8bbd0 !important}.mdl-color--pink-100{background-color:#f8bbd0 !important}.mdl-color-text--pink-200{color:#f48fb1 !important}.mdl-color--pink-200{background-color:#f48fb1 !important}.mdl-color-text--pink-300{color:#f06292 !important}.mdl-color--pink-300{background-color:#f06292 !important}.mdl-color-text--pink-400{color:#ec407a !important}.mdl-color--pink-400{background-color:#ec407a !important}.mdl-color-text--pink-500{color:#e91e63 !important}.mdl-color--pink-500{background-color:#e91e63 !important}.mdl-color-text--pink-600{color:#d81b60 !important}.mdl-color--pink-600{background-color:#d81b60 !important}.mdl-color-text--pink-700{color:#c2185b !important}.mdl-color--pink-700{background-color:#c2185b !important}.mdl-color-text--pink-800{color:#ad1457 !important}.mdl-color--pink-800{background-color:#ad1457 !important}.mdl-color-text--pink-900{color:#880e4f !important}.mdl-color--pink-900{background-color:#880e4f !important}.mdl-color-text--pink-A100{color:#ff80ab !important}.mdl-color--pink-A100{background-color:#ff80ab !important}.mdl-color-text--pink-A200{color:#ff4081 !important}.mdl-color--pink-A200{background-color:#ff4081 !important}.mdl-color-text--pink-A400{color:#f50057 !important}.mdl-color--pink-A400{background-color:#f50057 !important}.mdl-color-text--pink-A700{color:#c51162 !important}.mdl-color--pink-A700{background-color:#c51162 !important}.mdl-color-text--purple{color:#9c27b0 !important}.mdl-color--purple{background-color:#9c27b0 !important}.mdl-color-text--purple-50{color:#f3e5f5 !important}.mdl-color--purple-50{background-color:#f3e5f5 !important}.mdl-color-text--purple-100{color:#e1bee7 !important}.mdl-color--purple-100{background-color:#e1bee7 !important}.mdl-color-text--purple-200{color:#ce93d8 !important}.mdl-color--purple-200{background-color:#ce93d8 !important}.mdl-color-text--purple-300{color:#ba68c8 !important}.mdl-color--purple-300{background-color:#ba68c8 !important}.mdl-color-text--purple-400{color:#ab47bc !important}.mdl-color--purple-400{background-color:#ab47bc !important}.mdl-color-text--purple-500{color:#9c27b0 !important}.mdl-color--purple-500{background-color:#9c27b0 !important}.mdl-color-text--purple-600{color:#8e24aa !important}.mdl-color--purple-600{background-color:#8e24aa !important}.mdl-color-text--purple-700{color:#7b1fa2 !important}.mdl-color--purple-700{background-color:#7b1fa2 !important}.mdl-color-text--purple-800{color:#6a1b9a !important}.mdl-color--purple-800{background-color:#6a1b9a !important}.mdl-color-text--purple-900{color:#4a148c !important}.mdl-color--purple-900{background-color:#4a148c !important}.mdl-color-text--purple-A100{color:#ea80fc !important}.mdl-color--purple-A100{background-color:#ea80fc !important}.mdl-color-text--purple-A200{color:#e040fb !important}.mdl-color--purple-A200{background-color:#e040fb !important}.mdl-color-text--purple-A400{color:#d500f9 !important}.mdl-color--purple-A400{background-color:#d500f9 !important}.mdl-color-text--purple-A700{color:#a0f !important}.mdl-color--purple-A700{background-color:#a0f !important}.mdl-color-text--deep-purple{color:#673ab7 !important}.mdl-color--deep-purple{background-color:#673ab7 !important}.mdl-color-text--deep-purple-50{color:#ede7f6 !important}.mdl-color--deep-purple-50{background-color:#ede7f6 !important}.mdl-color-text--deep-purple-100{color:#d1c4e9 !important}.mdl-color--deep-purple-100{background-color:#d1c4e9 !important}.mdl-color-text--deep-purple-200{color:#b39ddb !important}.mdl-color--deep-purple-200{background-color:#b39ddb !important}.mdl-color-text--deep-purple-300{color:#9575cd !important}.mdl-color--deep-purple-300{background-color:#9575cd !important}.mdl-color-text--deep-purple-400{color:#7e57c2 !important}.mdl-color--deep-purple-400{background-color:#7e57c2 !important}.mdl-color-text--deep-purple-500{color:#673ab7 !important}.mdl-color--deep-purple-500{background-color:#673ab7 !important}.mdl-color-text--deep-purple-600{color:#5e35b1 !important}.mdl-color--deep-purple-600{background-color:#5e35b1 !important}.mdl-color-text--deep-purple-700{color:#512da8 !important}.mdl-color--deep-purple-700{background-color:#512da8 !important}.mdl-color-text--deep-purple-800{color:#4527a0 !important}.mdl-color--deep-purple-800{background-color:#4527a0 !important}.mdl-color-text--deep-purple-900{color:#311b92 !important}.mdl-color--deep-purple-900{background-color:#311b92 !important}.mdl-color-text--deep-purple-A100{color:#b388ff !important}.mdl-color--deep-purple-A100{background-color:#b388ff !important}.mdl-color-text--deep-purple-A200{color:#7c4dff !important}.mdl-color--deep-purple-A200{background-color:#7c4dff !important}.mdl-color-text--deep-purple-A400{color:#651fff !important}.mdl-color--deep-purple-A400{background-color:#651fff !important}.mdl-color-text--deep-purple-A700{color:#6200ea !important}.mdl-color--deep-purple-A700{background-color:#6200ea !important}.mdl-color-text--indigo{color:#3f51b5 !important}.mdl-color--indigo{background-color:#3f51b5 !important}.mdl-color-text--indigo-50{color:#e8eaf6 !important}.mdl-color--indigo-50{background-color:#e8eaf6 !important}.mdl-color-text--indigo-100{color:#c5cae9 !important}.mdl-color--indigo-100{background-color:#c5cae9 !important}.mdl-color-text--indigo-200{color:#9fa8da !important}.mdl-color--indigo-200{background-color:#9fa8da !important}.mdl-color-text--indigo-300{color:#7986cb !important}.mdl-color--indigo-300{background-color:#7986cb !important}.mdl-color-text--indigo-400{color:#5c6bc0 !important}.mdl-color--indigo-400{background-color:#5c6bc0 !important}.mdl-color-text--indigo-500{color:#3f51b5 !important}.mdl-color--indigo-500{background-color:#3f51b5 !important}.mdl-color-text--indigo-600{color:#3949ab !important}.mdl-color--indigo-600{background-color:#3949ab !important}.mdl-color-text--indigo-700{color:#303f9f !important}.mdl-color--indigo-700{background-color:#303f9f !important}.mdl-color-text--indigo-800{color:#283593 !important}.mdl-color--indigo-800{background-color:#283593 !important}.mdl-color-text--indigo-900{color:#1a237e !important}.mdl-color--indigo-900{background-color:#1a237e !important}.mdl-color-text--indigo-A100{color:#8c9eff !important}.mdl-color--indigo-A100{background-color:#8c9eff !important}.mdl-color-text--indigo-A200{color:#536dfe !important}.mdl-color--indigo-A200{background-color:#536dfe !important}.mdl-color-text--indigo-A400{color:#3d5afe !important}.mdl-color--indigo-A400{background-color:#3d5afe !important}.mdl-color-text--indigo-A700{color:#304ffe !important}.mdl-color--indigo-A700{background-color:#304ffe !important}.mdl-color-text--blue{color:#2196f3 !important}.mdl-color--blue{background-color:#2196f3 !important}.mdl-color-text--blue-50{color:#e3f2fd !important}.mdl-color--blue-50{background-color:#e3f2fd !important}.mdl-color-text--blue-100{color:#bbdefb !important}.mdl-color--blue-100{background-color:#bbdefb !important}.mdl-color-text--blue-200{color:#90caf9 !important}.mdl-color--blue-200{background-color:#90caf9 !important}.mdl-color-text--blue-300{color:#64b5f6 !important}.mdl-color--blue-300{background-color:#64b5f6 !important}.mdl-color-text--blue-400{color:#42a5f5 !important}.mdl-color--blue-400{background-color:#42a5f5 !important}.mdl-color-text--blue-500{color:#2196f3 !important}.mdl-color--blue-500{background-color:#2196f3 !important}.mdl-color-text--blue-600{color:#1e88e5 !important}.mdl-color--blue-600{background-color:#1e88e5 !important}.mdl-color-text--blue-700{color:#1976d2 !important}.mdl-color--blue-700{background-color:#1976d2 !important}.mdl-color-text--blue-800{color:#1565c0 !important}.mdl-color--blue-800{background-color:#1565c0 !important}.mdl-color-text--blue-900{color:#0d47a1 !important}.mdl-color--blue-900{background-color:#0d47a1 !important}.mdl-color-text--blue-A100{color:#82b1ff !important}.mdl-color--blue-A100{background-color:#82b1ff !important}.mdl-color-text--blue-A200{color:#448aff !important}.mdl-color--blue-A200{background-color:#448aff !important}.mdl-color-text--blue-A400{color:#2979ff !important}.mdl-color--blue-A400{background-color:#2979ff !important}.mdl-color-text--blue-A700{color:#2962ff !important}.mdl-color--blue-A700{background-color:#2962ff !important}.mdl-color-text--light-blue{color:#03a9f4 !important}.mdl-color--light-blue{background-color:#03a9f4 !important}.mdl-color-text--light-blue-50{color:#e1f5fe !important}.mdl-color--light-blue-50{background-color:#e1f5fe !important}.mdl-color-text--light-blue-100{color:#b3e5fc !important}.mdl-color--light-blue-100{background-color:#b3e5fc !important}.mdl-color-text--light-blue-200{color:#81d4fa !important}.mdl-color--light-blue-200{background-color:#81d4fa !important}.mdl-color-text--light-blue-300{color:#4fc3f7 !important}.mdl-color--light-blue-300{background-color:#4fc3f7 !important}.mdl-color-text--light-blue-400{color:#29b6f6 !important}.mdl-color--light-blue-400{background-color:#29b6f6 !important}.mdl-color-text--light-blue-500{color:#03a9f4 !important}.mdl-color--light-blue-500{background-color:#03a9f4 !important}.mdl-color-text--light-blue-600{color:#039be5 !important}.mdl-color--light-blue-600{background-color:#039be5 !important}.mdl-color-text--light-blue-700{color:#0288d1 !important}.mdl-color--light-blue-700{background-color:#0288d1 !important}.mdl-color-text--light-blue-800{color:#0277bd !important}.mdl-color--light-blue-800{background-color:#0277bd !important}.mdl-color-text--light-blue-900{color:#01579b !important}.mdl-color--light-blue-900{background-color:#01579b !important}.mdl-color-text--light-blue-A100{color:#80d8ff !important}.mdl-color--light-blue-A100{background-color:#80d8ff !important}.mdl-color-text--light-blue-A200{color:#40c4ff !important}.mdl-color--light-blue-A200{background-color:#40c4ff !important}.mdl-color-text--light-blue-A400{color:#00b0ff !important}.mdl-color--light-blue-A400{background-color:#00b0ff !important}.mdl-color-text--light-blue-A700{color:#0091ea !important}.mdl-color--light-blue-A700{background-color:#0091ea !important}.mdl-color-text--cyan{color:#00bcd4 !important}.mdl-color--cyan{background-color:#00bcd4 !important}.mdl-color-text--cyan-50{color:#e0f7fa !important}.mdl-color--cyan-50{background-color:#e0f7fa !important}.mdl-color-text--cyan-100{color:#b2ebf2 !important}.mdl-color--cyan-100{background-color:#b2ebf2 !important}.mdl-color-text--cyan-200{color:#80deea !important}.mdl-color--cyan-200{background-color:#80deea !important}.mdl-color-text--cyan-300{color:#4dd0e1 !important}.mdl-color--cyan-300{background-color:#4dd0e1 !important}.mdl-color-text--cyan-400{color:#26c6da !important}.mdl-color--cyan-400{background-color:#26c6da !important}.mdl-color-text--cyan-500{color:#00bcd4 !important}.mdl-color--cyan-500{background-color:#00bcd4 !important}.mdl-color-text--cyan-600{color:#00acc1 !important}.mdl-color--cyan-600{background-color:#00acc1 !important}.mdl-color-text--cyan-700{color:#0097a7 !important}.mdl-color--cyan-700{background-color:#0097a7 !important}.mdl-color-text--cyan-800{color:#00838f !important}.mdl-color--cyan-800{background-color:#00838f !important}.mdl-color-text--cyan-900{color:#006064 !important}.mdl-color--cyan-900{background-color:#006064 !important}.mdl-color-text--cyan-A100{color:#84ffff !important}.mdl-color--cyan-A100{background-color:#84ffff !important}.mdl-color-text--cyan-A200{color:#18ffff !important}.mdl-color--cyan-A200{background-color:#18ffff !important}.mdl-color-text--cyan-A400{color:#00e5ff !important}.mdl-color--cyan-A400{background-color:#00e5ff !important}.mdl-color-text--cyan-A700{color:#00b8d4 !important}.mdl-color--cyan-A700{background-color:#00b8d4 !important}.mdl-color-text--teal{color:#009688 !important}.mdl-color--teal{background-color:#009688 !important}.mdl-color-text--teal-50{color:#e0f2f1 !important}.mdl-color--teal-50{background-color:#e0f2f1 !important}.mdl-color-text--teal-100{color:#b2dfdb !important}.mdl-color--teal-100{background-color:#b2dfdb !important}.mdl-color-text--teal-200{color:#80cbc4 !important}.mdl-color--teal-200{background-color:#80cbc4 !important}.mdl-color-text--teal-300{color:#4db6ac !important}.mdl-color--teal-300{background-color:#4db6ac !important}.mdl-color-text--teal-400{color:#26a69a !important}.mdl-color--teal-400{background-color:#26a69a !important}.mdl-color-text--teal-500{color:#009688 !important}.mdl-color--teal-500{background-color:#009688 !important}.mdl-color-text--teal-600{color:#00897b !important}.mdl-color--teal-600{background-color:#00897b !important}.mdl-color-text--teal-700{color:#00796b !important}.mdl-color--teal-700{background-color:#00796b !important}.mdl-color-text--teal-800{color:#00695c !important}.mdl-color--teal-800{background-color:#00695c !important}.mdl-color-text--teal-900{color:#004d40 !important}.mdl-color--teal-900{background-color:#004d40 !important}.mdl-color-text--teal-A100{color:#a7ffeb !important}.mdl-color--teal-A100{background-color:#a7ffeb !important}.mdl-color-text--teal-A200{color:#64ffda !important}.mdl-color--teal-A200{background-color:#64ffda !important}.mdl-color-text--teal-A400{color:#1de9b6 !important}.mdl-color--teal-A400{background-color:#1de9b6 !important}.mdl-color-text--teal-A700{color:#00bfa5 !important}.mdl-color--teal-A700{background-color:#00bfa5 !important}.mdl-color-text--green{color:#4caf50 !important}.mdl-color--green{background-color:#4caf50 !important}.mdl-color-text--green-50{color:#e8f5e9 !important}.mdl-color--green-50{background-color:#e8f5e9 !important}.mdl-color-text--green-100{color:#c8e6c9 !important}.mdl-color--green-100{background-color:#c8e6c9 !important}.mdl-color-text--green-200{color:#a5d6a7 !important}.mdl-color--green-200{background-color:#a5d6a7 !important}.mdl-color-text--green-300{color:#81c784 !important}.mdl-color--green-300{background-color:#81c784 !important}.mdl-color-text--green-400{color:#66bb6a !important}.mdl-color--green-400{background-color:#66bb6a !important}.mdl-color-text--green-500{color:#4caf50 !important}.mdl-color--green-500{background-color:#4caf50 !important}.mdl-color-text--green-600{color:#43a047 !important}.mdl-color--green-600{background-color:#43a047 !important}.mdl-color-text--green-700{color:#388e3c !important}.mdl-color--green-700{background-color:#388e3c !important}.mdl-color-text--green-800{color:#2e7d32 !important}.mdl-color--green-800{background-color:#2e7d32 !important}.mdl-color-text--green-900{color:#1b5e20 !important}.mdl-color--green-900{background-color:#1b5e20 !important}.mdl-color-text--green-A100{color:#b9f6ca !important}.mdl-color--green-A100{background-color:#b9f6ca !important}.mdl-color-text--green-A200{color:#69f0ae !important}.mdl-color--green-A200{background-color:#69f0ae !important}.mdl-color-text--green-A400{color:#00e676 !important}.mdl-color--green-A400{background-color:#00e676 !important}.mdl-color-text--green-A700{color:#00c853 !important}.mdl-color--green-A700{background-color:#00c853 !important}.mdl-color-text--light-green{color:#8bc34a !important}.mdl-color--light-green{background-color:#8bc34a !important}.mdl-color-text--light-green-50{color:#f1f8e9 !important}.mdl-color--light-green-50{background-color:#f1f8e9 !important}.mdl-color-text--light-green-100{color:#dcedc8 !important}.mdl-color--light-green-100{background-color:#dcedc8 !important}.mdl-color-text--light-green-200{color:#c5e1a5 !important}.mdl-color--light-green-200{background-color:#c5e1a5 !important}.mdl-color-text--light-green-300{color:#aed581 !important}.mdl-color--light-green-300{background-color:#aed581 !important}.mdl-color-text--light-green-400{color:#9ccc65 !important}.mdl-color--light-green-400{background-color:#9ccc65 !important}.mdl-color-text--light-green-500{color:#8bc34a !important}.mdl-color--light-green-500{background-color:#8bc34a !important}.mdl-color-text--light-green-600{color:#7cb342 !important}.mdl-color--light-green-600{background-color:#7cb342 !important}.mdl-color-text--light-green-700{color:#689f38 !important}.mdl-color--light-green-700{background-color:#689f38 !important}.mdl-color-text--light-green-800{color:#558b2f !important}.mdl-color--light-green-800{background-color:#558b2f !important}.mdl-color-text--light-green-900{color:#33691e !important}.mdl-color--light-green-900{background-color:#33691e !important}.mdl-color-text--light-green-A100{color:#ccff90 !important}.mdl-color--light-green-A100{background-color:#ccff90 !important}.mdl-color-text--light-green-A200{color:#b2ff59 !important}.mdl-color--light-green-A200{background-color:#b2ff59 !important}.mdl-color-text--light-green-A400{color:#76ff03 !important}.mdl-color--light-green-A400{background-color:#76ff03 !important}.mdl-color-text--light-green-A700{color:#64dd17 !important}.mdl-color--light-green-A700{background-color:#64dd17 !important}.mdl-color-text--lime{color:#cddc39 !important}.mdl-color--lime{background-color:#cddc39 !important}.mdl-color-text--lime-50{color:#f9fbe7 !important}.mdl-color--lime-50{background-color:#f9fbe7 !important}.mdl-color-text--lime-100{color:#f0f4c3 !important}.mdl-color--lime-100{background-color:#f0f4c3 !important}.mdl-color-text--lime-200{color:#e6ee9c !important}.mdl-color--lime-200{background-color:#e6ee9c !important}.mdl-color-text--lime-300{color:#dce775 !important}.mdl-color--lime-300{background-color:#dce775 !important}.mdl-color-text--lime-400{color:#d4e157 !important}.mdl-color--lime-400{background-color:#d4e157 !important}.mdl-color-text--lime-500{color:#cddc39 !important}.mdl-color--lime-500{background-color:#cddc39 !important}.mdl-color-text--lime-600{color:#c0ca33 !important}.mdl-color--lime-600{background-color:#c0ca33 !important}.mdl-color-text--lime-700{color:#afb42b !important}.mdl-color--lime-700{background-color:#afb42b !important}.mdl-color-text--lime-800{color:#9e9d24 !important}.mdl-color--lime-800{background-color:#9e9d24 !important}.mdl-color-text--lime-900{color:#827717 !important}.mdl-color--lime-900{background-color:#827717 !important}.mdl-color-text--lime-A100{color:#f4ff81 !important}.mdl-color--lime-A100{background-color:#f4ff81 !important}.mdl-color-text--lime-A200{color:#eeff41 !important}.mdl-color--lime-A200{background-color:#eeff41 !important}.mdl-color-text--lime-A400{color:#c6ff00 !important}.mdl-color--lime-A400{background-color:#c6ff00 !important}.mdl-color-text--lime-A700{color:#aeea00 !important}.mdl-color--lime-A700{background-color:#aeea00 !important}.mdl-color-text--yellow{color:#ffeb3b !important}.mdl-color--yellow{background-color:#ffeb3b !important}.mdl-color-text--yellow-50{color:#fffde7 !important}.mdl-color--yellow-50{background-color:#fffde7 !important}.mdl-color-text--yellow-100{color:#fff9c4 !important}.mdl-color--yellow-100{background-color:#fff9c4 !important}.mdl-color-text--yellow-200{color:#fff59d !important}.mdl-color--yellow-200{background-color:#fff59d !important}.mdl-color-text--yellow-300{color:#fff176 !important}.mdl-color--yellow-300{background-color:#fff176 !important}.mdl-color-text--yellow-400{color:#ffee58 !important}.mdl-color--yellow-400{background-color:#ffee58 !important}.mdl-color-text--yellow-500{color:#ffeb3b !important}.mdl-color--yellow-500{background-color:#ffeb3b !important}.mdl-color-text--yellow-600{color:#fdd835 !important}.mdl-color--yellow-600{background-color:#fdd835 !important}.mdl-color-text--yellow-700{color:#fbc02d !important}.mdl-color--yellow-700{background-color:#fbc02d !important}.mdl-color-text--yellow-800{color:#f9a825 !important}.mdl-color--yellow-800{background-color:#f9a825 !important}.mdl-color-text--yellow-900{color:#f57f17 !important}.mdl-color--yellow-900{background-color:#f57f17 !important}.mdl-color-text--yellow-A100{color:#ffff8d !important}.mdl-color--yellow-A100{background-color:#ffff8d !important}.mdl-color-text--yellow-A200{color:#ff0 !important}.mdl-color--yellow-A200{background-color:#ff0 !important}.mdl-color-text--yellow-A400{color:#ffea00 !important}.mdl-color--yellow-A400{background-color:#ffea00 !important}.mdl-color-text--yellow-A700{color:#ffd600 !important}.mdl-color--yellow-A700{background-color:#ffd600 !important}.mdl-color-text--amber{color:#ffc107 !important}.mdl-color--amber{background-color:#ffc107 !important}.mdl-color-text--amber-50{color:#fff8e1 !important}.mdl-color--amber-50{background-color:#fff8e1 !important}.mdl-color-text--amber-100{color:#ffecb3 !important}.mdl-color--amber-100{background-color:#ffecb3 !important}.mdl-color-text--amber-200{color:#ffe082 !important}.mdl-color--amber-200{background-color:#ffe082 !important}.mdl-color-text--amber-300{color:#ffd54f !important}.mdl-color--amber-300{background-color:#ffd54f !important}.mdl-color-text--amber-400{color:#ffca28 !important}.mdl-color--amber-400{background-color:#ffca28 !important}.mdl-color-text--amber-500{color:#ffc107 !important}.mdl-color--amber-500{background-color:#ffc107 !important}.mdl-color-text--amber-600{color:#ffb300 !important}.mdl-color--amber-600{background-color:#ffb300 !important}.mdl-color-text--amber-700{color:#ffa000 !important}.mdl-color--amber-700{background-color:#ffa000 !important}.mdl-color-text--amber-800{color:#ff8f00 !important}.mdl-color--amber-800{background-color:#ff8f00 !important}.mdl-color-text--amber-900{color:#ff6f00 !important}.mdl-color--amber-900{background-color:#ff6f00 !important}.mdl-color-text--amber-A100{color:#ffe57f !important}.mdl-color--amber-A100{background-color:#ffe57f !important}.mdl-color-text--amber-A200{color:#ffd740 !important}.mdl-color--amber-A200{background-color:#ffd740 !important}.mdl-color-text--amber-A400{color:#ffc400 !important}.mdl-color--amber-A400{background-color:#ffc400 !important}.mdl-color-text--amber-A700{color:#ffab00 !important}.mdl-color--amber-A700{background-color:#ffab00 !important}.mdl-color-text--orange{color:#ff9800 !important}.mdl-color--orange{background-color:#ff9800 !important}.mdl-color-text--orange-50{color:#fff3e0 !important}.mdl-color--orange-50{background-color:#fff3e0 !important}.mdl-color-text--orange-100{color:#ffe0b2 !important}.mdl-color--orange-100{background-color:#ffe0b2 !important}.mdl-color-text--orange-200{color:#ffcc80 !important}.mdl-color--orange-200{background-color:#ffcc80 !important}.mdl-color-text--orange-300{color:#ffb74d !important}.mdl-color--orange-300{background-color:#ffb74d !important}.mdl-color-text--orange-400{color:#ffa726 !important}.mdl-color--orange-400{background-color:#ffa726 !important}.mdl-color-text--orange-500{color:#ff9800 !important}.mdl-color--orange-500{background-color:#ff9800 !important}.mdl-color-text--orange-600{color:#fb8c00 !important}.mdl-color--orange-600{background-color:#fb8c00 !important}.mdl-color-text--orange-700{color:#f57c00 !important}.mdl-color--orange-700{background-color:#f57c00 !important}.mdl-color-text--orange-800{color:#ef6c00 !important}.mdl-color--orange-800{background-color:#ef6c00 !important}.mdl-color-text--orange-900{color:#e65100 !important}.mdl-color--orange-900{background-color:#e65100 !important}.mdl-color-text--orange-A100{color:#ffd180 !important}.mdl-color--orange-A100{background-color:#ffd180 !important}.mdl-color-text--orange-A200{color:#ffab40 !important}.mdl-color--orange-A200{background-color:#ffab40 !important}.mdl-color-text--orange-A400{color:#ff9100 !important}.mdl-color--orange-A400{background-color:#ff9100 !important}.mdl-color-text--orange-A700{color:#ff6d00 !important}.mdl-color--orange-A700{background-color:#ff6d00 !important}.mdl-color-text--deep-orange{color:#ff5722 !important}.mdl-color--deep-orange{background-color:#ff5722 !important}.mdl-color-text--deep-orange-50{color:#fbe9e7 !important}.mdl-color--deep-orange-50{background-color:#fbe9e7 !important}.mdl-color-text--deep-orange-100{color:#ffccbc !important}.mdl-color--deep-orange-100{background-color:#ffccbc !important}.mdl-color-text--deep-orange-200{color:#ffab91 !important}.mdl-color--deep-orange-200{background-color:#ffab91 !important}.mdl-color-text--deep-orange-300{color:#ff8a65 !important}.mdl-color--deep-orange-300{background-color:#ff8a65 !important}.mdl-color-text--deep-orange-400{color:#ff7043 !important}.mdl-color--deep-orange-400{background-color:#ff7043 !important}.mdl-color-text--deep-orange-500{color:#ff5722 !important}.mdl-color--deep-orange-500{background-color:#ff5722 !important}.mdl-color-text--deep-orange-600{color:#f4511e !important}.mdl-color--deep-orange-600{background-color:#f4511e !important}.mdl-color-text--deep-orange-700{color:#e64a19 !important}.mdl-color--deep-orange-700{background-color:#e64a19 !important}.mdl-color-text--deep-orange-800{color:#d84315 !important}.mdl-color--deep-orange-800{background-color:#d84315 !important}.mdl-color-text--deep-orange-900{color:#bf360c !important}.mdl-color--deep-orange-900{background-color:#bf360c !important}.mdl-color-text--deep-orange-A100{color:#ff9e80 !important}.mdl-color--deep-orange-A100{background-color:#ff9e80 !important}.mdl-color-text--deep-orange-A200{color:#ff6e40 !important}.mdl-color--deep-orange-A200{background-color:#ff6e40 !important}.mdl-color-text--deep-orange-A400{color:#ff3d00 !important}.mdl-color--deep-orange-A400{background-color:#ff3d00 !important}.mdl-color-text--deep-orange-A700{color:#dd2c00 !important}.mdl-color--deep-orange-A700{background-color:#dd2c00 !important}.mdl-color-text--brown{color:#795548 !important}.mdl-color--brown{background-color:#795548 !important}.mdl-color-text--brown-50{color:#efebe9 !important}.mdl-color--brown-50{background-color:#efebe9 !important}.mdl-color-text--brown-100{color:#d7ccc8 !important}.mdl-color--brown-100{background-color:#d7ccc8 !important}.mdl-color-text--brown-200{color:#bcaaa4 !important}.mdl-color--brown-200{background-color:#bcaaa4 !important}.mdl-color-text--brown-300{color:#a1887f !important}.mdl-color--brown-300{background-color:#a1887f !important}.mdl-color-text--brown-400{color:#8d6e63 !important}.mdl-color--brown-400{background-color:#8d6e63 !important}.mdl-color-text--brown-500{color:#795548 !important}.mdl-color--brown-500{background-color:#795548 !important}.mdl-color-text--brown-600{color:#6d4c41 !important}.mdl-color--brown-600{background-color:#6d4c41 !important}.mdl-color-text--brown-700{color:#5d4037 !important}.mdl-color--brown-700{background-color:#5d4037 !important}.mdl-color-text--brown-800{color:#4e342e !important}.mdl-color--brown-800{background-color:#4e342e !important}.mdl-color-text--brown-900{color:#3e2723 !important}.mdl-color--brown-900{background-color:#3e2723 !important}.mdl-color-text--grey{color:#9e9e9e !important}.mdl-color--grey{background-color:#9e9e9e !important}.mdl-color-text--grey-50{color:#fafafa !important}.mdl-color--grey-50{background-color:#fafafa !important}.mdl-color-text--grey-100{color:#f5f5f5 !important}.mdl-color--grey-100{background-color:#f5f5f5 !important}.mdl-color-text--grey-200{color:#eee !important}.mdl-color--grey-200{background-color:#eee !important}.mdl-color-text--grey-300{color:#e0e0e0 !important}.mdl-color--grey-300{background-color:#e0e0e0 !important}.mdl-color-text--grey-400{color:#bdbdbd !important}.mdl-color--grey-400{background-color:#bdbdbd !important}.mdl-color-text--grey-500{color:#9e9e9e !important}.mdl-color--grey-500{background-color:#9e9e9e !important}.mdl-color-text--grey-600{color:#757575 !important}.mdl-color--grey-600{background-color:#757575 !important}.mdl-color-text--grey-700{color:#616161 !important}.mdl-color--grey-700{background-color:#616161 !important}.mdl-color-text--grey-800{color:#424242 !important}.mdl-color--grey-800{background-color:#424242 !important}.mdl-color-text--grey-900{color:#212121 !important}.mdl-color--grey-900{background-color:#212121 !important}.mdl-color-text--blue-grey{color:#607d8b !important}.mdl-color--blue-grey{background-color:#607d8b !important}.mdl-color-text--blue-grey-50{color:#eceff1 !important}.mdl-color--blue-grey-50{background-color:#eceff1 !important}.mdl-color-text--blue-grey-100{color:#cfd8dc !important}.mdl-color--blue-grey-100{background-color:#cfd8dc !important}.mdl-color-text--blue-grey-200{color:#b0bec5 !important}.mdl-color--blue-grey-200{background-color:#b0bec5 !important}.mdl-color-text--blue-grey-300{color:#90a4ae !important}.mdl-color--blue-grey-300{background-color:#90a4ae !important}.mdl-color-text--blue-grey-400{color:#78909c !important}.mdl-color--blue-grey-400{background-color:#78909c !important}.mdl-color-text--blue-grey-500{color:#607d8b !important}.mdl-color--blue-grey-500{background-color:#607d8b !important}.mdl-color-text--blue-grey-600{color:#546e7a !important}.mdl-color--blue-grey-600{background-color:#546e7a !important}.mdl-color-text--blue-grey-700{color:#455a64 !important}.mdl-color--blue-grey-700{background-color:#455a64 !important}.mdl-color-text--blue-grey-800{color:#37474f !important}.mdl-color--blue-grey-800{background-color:#37474f !important}.mdl-color-text--blue-grey-900{color:#263238 !important}.mdl-color--blue-grey-900{background-color:#263238 !important}.mdl-color--black{background-color:#000 !important}.mdl-color-text--black{color:#000 !important}.mdl-color--white{background-color:#fff !important}.mdl-color-text--white{color:#fff !important}.mdl-color--primary{background-color:#3f51b5 !important}.mdl-color--primary-contrast{background-color:#fff !important}.mdl-color--primary-dark{background-color:#303f9f !important}.mdl-color--accent{background-color:#ff4081 !important}.mdl-color--accent-contrast{background-color:#fff !important}.mdl-color-text--primary{color:#3f51b5 !important}.mdl-color-text--primary-contrast{color:#fff !important}.mdl-color-text--primary-dark{color:#303f9f !important}.mdl-color-text--accent{color:#ff4081 !important}.mdl-color-text--accent-contrast{color:#fff !important}.mdl-ripple{background:#000;border-radius:50%;height:50px;left:0;opacity:0;pointer-events:none;position:absolute;top:0;-webkit-transform:translate(-50%,-50%);transform:translate(-50%,-50%);width:50px;overflow:hidden}.mdl-ripple.is-animating{transition:transform .3s cubic-bezier(0,0,.2,1),width .3s cubic-bezier(0,0,.2,1),height .3s cubic-bezier(0,0,.2,1),opacity .6s cubic-bezier(0,0,.2,1);transition:transform .3s cubic-bezier(0,0,.2,1),width .3s cubic-bezier(0,0,.2,1),height .3s cubic-bezier(0,0,.2,1),opacity .6s cubic-bezier(0,0,.2,1),-webkit-transform .3s cubic-bezier(0,0,.2,1)}.mdl-ripple.is-visible{opacity:.3}.mdl-animation--default,.mdl-animation--fast-out-slow-in{transition-timing-function:cubic-bezier(.4,0,.2,1)}.mdl-animation--linear-out-slow-in{transition-timing-function:cubic-bezier(0,0,.2,1)}.mdl-animation--fast-out-linear-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.mdl-badge{position:relative;white-space:nowrap;margin-right:24px}.mdl-badge:not([data-badge]){margin-right:auto}.mdl-badge[data-badge]:after{content:attr(data-badge);display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center;-webkit-align-content:center;-ms-flex-line-pack:center;align-content:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;position:absolute;top:-11px;right:-24px;font-family:"Roboto","Helvetica","Arial",sans-serif;font-weight:600;font-size:12px;width:22px;height:22px;border-radius:50%;background:#ff4081;color:#fff}.mdl-button .mdl-badge[data-badge]:after{top:-10px;right:-5px}.mdl-badge.mdl-badge--no-background[data-badge]:after{color:#ff4081;background:rgba(255,255,255,.2);box-shadow:0 0 1px gray}.mdl-badge.mdl-badge--overlap{margin-right:10px}.mdl-badge.mdl-badge--overlap:after{right:-10px}.mdl-button{background:0 0;border:none;border-radius:2px;color:#000;position:relative;height:36px;margin:0;min-width:64px;padding:0 16px;display:inline-block;font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:14px;font-weight:500;text-transform:uppercase;letter-spacing:0;overflow:hidden;will-change:box-shadow;transition:box-shadow .2s cubic-bezier(.4,0,1,1),background-color .2s cubic-bezier(.4,0,.2,1),color .2s cubic-bezier(.4,0,.2,1);outline:none;cursor:pointer;text-decoration:none;text-align:center;line-height:36px;vertical-align:middle}.mdl-button::-moz-focus-inner{border:0}.mdl-button:hover{background-color:rgba(158,158,158,.2)}.mdl-button:focus:not(:active){background-color:rgba(0,0,0,.12)}.mdl-button:active{background-color:rgba(158,158,158,.4)}.mdl-button.mdl-button--colored{color:#3f51b5}.mdl-button.mdl-button--colored:focus:not(:active){background-color:rgba(0,0,0,.12)}input.mdl-button[type="submit"]{-webkit-appearance:none}.mdl-button--raised{background:rgba(158,158,158,.2);box-shadow:0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12)}.mdl-button--raised:active{box-shadow:0 4px 5px 0 rgba(0,0,0,.14),0 1px 10px 0 rgba(0,0,0,.12),0 2px 4px -1px rgba(0,0,0,.2);background-color:rgba(158,158,158,.4)}.mdl-button--raised:focus:not(:active){box-shadow:0 0 8px rgba(0,0,0,.18),0 8px 16px rgba(0,0,0,.36);background-color:rgba(158,158,158,.4)}.mdl-button--raised.mdl-button--colored{background:#3f51b5;color:#fff}.mdl-button--raised.mdl-button--colored:hover{background-color:#3f51b5}.mdl-button--raised.mdl-button--colored:active{background-color:#3f51b5}.mdl-button--raised.mdl-button--colored:focus:not(:active){background-color:#3f51b5}.mdl-button--raised.mdl-button--colored .mdl-ripple{background:#fff}.mdl-button--fab{border-radius:50%;font-size:24px;height:56px;margin:auto;min-width:56px;width:56px;padding:0;overflow:hidden;background:rgba(158,158,158,.2);box-shadow:0 1px 1.5px 0 rgba(0,0,0,.12),0 1px 1px 0 rgba(0,0,0,.24);position:relative;line-height:normal}.mdl-button--fab .material-icons{position:absolute;top:50%;left:50%;-webkit-transform:translate(-12px,-12px);transform:translate(-12px,-12px);line-height:24px;width:24px}.mdl-button--fab.mdl-button--mini-fab{height:40px;min-width:40px;width:40px}.mdl-button--fab .mdl-button__ripple-container{border-radius:50%;-webkit-mask-image:-webkit-radial-gradient(circle,#fff,#000)}.mdl-button--fab:active{box-shadow:0 4px 5px 0 rgba(0,0,0,.14),0 1px 10px 0 rgba(0,0,0,.12),0 2px 4px -1px rgba(0,0,0,.2);background-color:rgba(158,158,158,.4)}.mdl-button--fab:focus:not(:active){box-shadow:0 0 8px rgba(0,0,0,.18),0 8px 16px rgba(0,0,0,.36);background-color:rgba(158,158,158,.4)}.mdl-button--fab.mdl-button--colored{background:#ff4081;color:#fff}.mdl-button--fab.mdl-button--colored:hover{background-color:#ff4081}.mdl-button--fab.mdl-button--colored:focus:not(:active){background-color:#ff4081}.mdl-button--fab.mdl-button--colored:active{background-color:#ff4081}.mdl-button--fab.mdl-button--colored .mdl-ripple{background:#fff}.mdl-button--icon{border-radius:50%;font-size:24px;height:32px;margin-left:0;margin-right:0;min-width:32px;width:32px;padding:0;overflow:hidden;color:inherit;line-height:normal}.mdl-button--icon .material-icons{position:absolute;top:50%;left:50%;-webkit-transform:translate(-12px,-12px);transform:translate(-12px,-12px);line-height:24px;width:24px}.mdl-button--icon.mdl-button--mini-icon{height:24px;min-width:24px;width:24px}.mdl-button--icon.mdl-button--mini-icon .material-icons{top:0;left:0}.mdl-button--icon .mdl-button__ripple-container{border-radius:50%;-webkit-mask-image:-webkit-radial-gradient(circle,#fff,#000)}.mdl-button__ripple-container{display:block;height:100%;left:0;position:absolute;top:0;width:100%;z-index:0;overflow:hidden}.mdl-button[disabled] .mdl-button__ripple-container .mdl-ripple,.mdl-button.mdl-button--disabled .mdl-button__ripple-container .mdl-ripple{background-color:transparent}.mdl-button--primary.mdl-button--primary{color:#3f51b5}.mdl-button--primary.mdl-button--primary .mdl-ripple{background:#fff}.mdl-button--primary.mdl-button--primary.mdl-button--raised,.mdl-button--primary.mdl-button--primary.mdl-button--fab{color:#fff;background-color:#3f51b5}.mdl-button--accent.mdl-button--accent{color:#ff4081}.mdl-button--accent.mdl-button--accent .mdl-ripple{background:#fff}.mdl-button--accent.mdl-button--accent.mdl-button--raised,.mdl-button--accent.mdl-button--accent.mdl-button--fab{color:#fff;background-color:#ff4081}.mdl-button[disabled][disabled],.mdl-button.mdl-button--disabled.mdl-button--disabled{color:rgba(0,0,0,.26);cursor:default;background-color:transparent}.mdl-button--fab[disabled][disabled],.mdl-button--fab.mdl-button--disabled.mdl-button--disabled{background-color:rgba(0,0,0,.12);color:rgba(0,0,0,.26)}.mdl-button--raised[disabled][disabled],.mdl-button--raised.mdl-button--disabled.mdl-button--disabled{background-color:rgba(0,0,0,.12);color:rgba(0,0,0,.26);box-shadow:none}.mdl-button--colored[disabled][disabled],.mdl-button--colored.mdl-button--disabled.mdl-button--disabled{color:rgba(0,0,0,.26)}.mdl-button .material-icons{vertical-align:middle}.mdl-card{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;font-size:16px;font-weight:400;min-height:200px;overflow:hidden;width:330px;z-index:1;position:relative;background:#fff;border-radius:2px;box-sizing:border-box}.mdl-card__media{background-color:#ff4081;background-repeat:repeat;background-position:50% 50%;background-size:cover;background-origin:padding-box;background-attachment:scroll;box-sizing:border-box}.mdl-card__title{-webkit-align-items:center;-ms-flex-align:center;align-items:center;color:#000;display:block;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-justify-content:stretch;-ms-flex-pack:stretch;justify-content:stretch;line-height:normal;padding:16px;-webkit-perspective-origin:165px 56px;perspective-origin:165px 56px;-webkit-transform-origin:165px 56px;transform-origin:165px 56px;box-sizing:border-box}.mdl-card__title.mdl-card--border{border-bottom:1px solid rgba(0,0,0,.1)}.mdl-card__title-text{-webkit-align-self:flex-end;-ms-flex-item-align:end;align-self:flex-end;color:inherit;display:block;display:-webkit-flex;display:-ms-flexbox;display:flex;font-size:24px;font-weight:300;line-height:normal;overflow:hidden;-webkit-transform-origin:149px 48px;transform-origin:149px 48px;margin:0}.mdl-card__subtitle-text{font-size:14px;color:rgba(0,0,0,.54);margin:0}.mdl-card__supporting-text{color:rgba(0,0,0,.54);font-size:1rem;line-height:18px;overflow:hidden;padding:16px;width:90%}.mdl-card__actions{font-size:16px;line-height:normal;width:100%;background-color:transparent;padding:8px;box-sizing:border-box}.mdl-card__actions.mdl-card--border{border-top:1px solid rgba(0,0,0,.1)}.mdl-card--expand{-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1}.mdl-card__menu{position:absolute;right:16px;top:16px}.mdl-checkbox{position:relative;z-index:1;vertical-align:middle;display:inline-block;box-sizing:border-box;width:100%;height:24px;margin:0;padding:0}.mdl-checkbox.is-upgraded{padding-left:24px}.mdl-checkbox__input{line-height:24px}.mdl-checkbox.is-upgraded .mdl-checkbox__input{position:absolute;width:0;height:0;margin:0;padding:0;opacity:0;-ms-appearance:none;-moz-appearance:none;-webkit-appearance:none;appearance:none;border:none}.mdl-checkbox__box-outline{position:absolute;top:3px;left:0;display:inline-block;box-sizing:border-box;width:16px;height:16px;margin:0;cursor:pointer;overflow:hidden;border:2px solid rgba(0,0,0,.54);border-radius:2px;z-index:2}.mdl-checkbox.is-checked .mdl-checkbox__box-outline{border:2px solid #3f51b5}fieldset[disabled] .mdl-checkbox .mdl-checkbox__box-outline,.mdl-checkbox.is-disabled .mdl-checkbox__box-outline{border:2px solid rgba(0,0,0,.26);cursor:auto}.mdl-checkbox__focus-helper{position:absolute;top:3px;left:0;display:inline-block;box-sizing:border-box;width:16px;height:16px;border-radius:50%;background-color:transparent}.mdl-checkbox.is-focused .mdl-checkbox__focus-helper{box-shadow:0 0 0 8px rgba(0,0,0,.1);background-color:rgba(0,0,0,.1)}.mdl-checkbox.is-focused.is-checked .mdl-checkbox__focus-helper{box-shadow:0 0 0 8px rgba(63,81,181,.26);background-color:rgba(63,81,181,.26)}.mdl-checkbox__tick-outline{position:absolute;top:0;left:0;height:100%;width:100%;-webkit-mask:url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgdmVyc2lvbj0iMS4xIgogICB2aWV3Qm94PSIwIDAgMSAxIgogICBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWluWU1pbiBtZWV0Ij4KICA8ZGVmcz4KICAgIDxjbGlwUGF0aCBpZD0iY2xpcCI+CiAgICAgIDxwYXRoCiAgICAgICAgIGQ9Ik0gMCwwIDAsMSAxLDEgMSwwIDAsMCB6IE0gMC44NTM0Mzc1LDAuMTY3MTg3NSAwLjk1OTY4NzUsMC4yNzMxMjUgMC40MjkzNzUsMC44MDM0Mzc1IDAuMzIzMTI1LDAuOTA5Njg3NSAwLjIxNzE4NzUsMC44MDM0Mzc1IDAuMDQwMzEyNSwwLjYyNjg3NSAwLjE0NjU2MjUsMC41MjA2MjUgMC4zMjMxMjUsMC42OTc1IDAuODUzNDM3NSwwLjE2NzE4NzUgeiIKICAgICAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZjtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZSIgLz4KICAgIDwvY2xpcFBhdGg+CiAgICA8bWFzayBpZD0ibWFzayIgbWFza1VuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgbWFza0NvbnRlbnRVbml0cz0ib2JqZWN0Qm91bmRpbmdCb3giPgogICAgICA8cGF0aAogICAgICAgICBkPSJNIDAsMCAwLDEgMSwxIDEsMCAwLDAgeiBNIDAuODUzNDM3NSwwLjE2NzE4NzUgMC45NTk2ODc1LDAuMjczMTI1IDAuNDI5Mzc1LDAuODAzNDM3NSAwLjMyMzEyNSwwLjkwOTY4NzUgMC4yMTcxODc1LDAuODAzNDM3NSAwLjA0MDMxMjUsMC42MjY4NzUgMC4xNDY1NjI1LDAuNTIwNjI1IDAuMzIzMTI1LDAuNjk3NSAwLjg1MzQzNzUsMC4xNjcxODc1IHoiCiAgICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmUiIC8+CiAgICA8L21hc2s+CiAgPC9kZWZzPgogIDxyZWN0CiAgICAgd2lkdGg9IjEiCiAgICAgaGVpZ2h0PSIxIgogICAgIHg9IjAiCiAgICAgeT0iMCIKICAgICBjbGlwLXBhdGg9InVybCgjY2xpcCkiCiAgICAgc3R5bGU9ImZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZSIgLz4KPC9zdmc+Cg==");mask:url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgdmVyc2lvbj0iMS4xIgogICB2aWV3Qm94PSIwIDAgMSAxIgogICBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWluWU1pbiBtZWV0Ij4KICA8ZGVmcz4KICAgIDxjbGlwUGF0aCBpZD0iY2xpcCI+CiAgICAgIDxwYXRoCiAgICAgICAgIGQ9Ik0gMCwwIDAsMSAxLDEgMSwwIDAsMCB6IE0gMC44NTM0Mzc1LDAuMTY3MTg3NSAwLjk1OTY4NzUsMC4yNzMxMjUgMC40MjkzNzUsMC44MDM0Mzc1IDAuMzIzMTI1LDAuOTA5Njg3NSAwLjIxNzE4NzUsMC44MDM0Mzc1IDAuMDQwMzEyNSwwLjYyNjg3NSAwLjE0NjU2MjUsMC41MjA2MjUgMC4zMjMxMjUsMC42OTc1IDAuODUzNDM3NSwwLjE2NzE4NzUgeiIKICAgICAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZjtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZSIgLz4KICAgIDwvY2xpcFBhdGg+CiAgICA8bWFzayBpZD0ibWFzayIgbWFza1VuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgbWFza0NvbnRlbnRVbml0cz0ib2JqZWN0Qm91bmRpbmdCb3giPgogICAgICA8cGF0aAogICAgICAgICBkPSJNIDAsMCAwLDEgMSwxIDEsMCAwLDAgeiBNIDAuODUzNDM3NSwwLjE2NzE4NzUgMC45NTk2ODc1LDAuMjczMTI1IDAuNDI5Mzc1LDAuODAzNDM3NSAwLjMyMzEyNSwwLjkwOTY4NzUgMC4yMTcxODc1LDAuODAzNDM3NSAwLjA0MDMxMjUsMC42MjY4NzUgMC4xNDY1NjI1LDAuNTIwNjI1IDAuMzIzMTI1LDAuNjk3NSAwLjg1MzQzNzUsMC4xNjcxODc1IHoiCiAgICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmUiIC8+CiAgICA8L21hc2s+CiAgPC9kZWZzPgogIDxyZWN0CiAgICAgd2lkdGg9IjEiCiAgICAgaGVpZ2h0PSIxIgogICAgIHg9IjAiCiAgICAgeT0iMCIKICAgICBjbGlwLXBhdGg9InVybCgjY2xpcCkiCiAgICAgc3R5bGU9ImZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZSIgLz4KPC9zdmc+Cg==");background:0 0;transition-duration:.28s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-property:background}.mdl-checkbox.is-checked .mdl-checkbox__tick-outline{background:#3f51b5 url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgdmVyc2lvbj0iMS4xIgogICB2aWV3Qm94PSIwIDAgMSAxIgogICBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWluWU1pbiBtZWV0Ij4KICA8cGF0aAogICAgIGQ9Ik0gMC4wNDAzODA1OSwwLjYyNjc3NjcgMC4xNDY0NDY2MSwwLjUyMDcxMDY4IDAuNDI5Mjg5MzIsMC44MDM1NTMzOSAwLjMyMzIyMzMsMC45MDk2MTk0MSB6IE0gMC4yMTcxNTcyOSwwLjgwMzU1MzM5IDAuODUzNTUzMzksMC4xNjcxNTcyOSAwLjk1OTYxOTQxLDAuMjczMjIzMyAwLjMyMzIyMzMsMC45MDk2MTk0MSB6IgogICAgIGlkPSJyZWN0Mzc4MCIKICAgICBzdHlsZT0iZmlsbDojZmZmZmZmO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lIiAvPgo8L3N2Zz4K")}fieldset[disabled] .mdl-checkbox.is-checked .mdl-checkbox__tick-outline,.mdl-checkbox.is-checked.is-disabled .mdl-checkbox__tick-outline{background:rgba(0,0,0,.26)url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgdmVyc2lvbj0iMS4xIgogICB2aWV3Qm94PSIwIDAgMSAxIgogICBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWluWU1pbiBtZWV0Ij4KICA8cGF0aAogICAgIGQ9Ik0gMC4wNDAzODA1OSwwLjYyNjc3NjcgMC4xNDY0NDY2MSwwLjUyMDcxMDY4IDAuNDI5Mjg5MzIsMC44MDM1NTMzOSAwLjMyMzIyMzMsMC45MDk2MTk0MSB6IE0gMC4yMTcxNTcyOSwwLjgwMzU1MzM5IDAuODUzNTUzMzksMC4xNjcxNTcyOSAwLjk1OTYxOTQxLDAuMjczMjIzMyAwLjMyMzIyMzMsMC45MDk2MTk0MSB6IgogICAgIGlkPSJyZWN0Mzc4MCIKICAgICBzdHlsZT0iZmlsbDojZmZmZmZmO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lIiAvPgo8L3N2Zz4K")}.mdl-checkbox__label{position:relative;cursor:pointer;font-size:16px;line-height:24px;margin:0}fieldset[disabled] .mdl-checkbox .mdl-checkbox__label,.mdl-checkbox.is-disabled .mdl-checkbox__label{color:rgba(0,0,0,.26);cursor:auto}.mdl-checkbox__ripple-container{position:absolute;z-index:2;top:-6px;left:-10px;box-sizing:border-box;width:36px;height:36px;border-radius:50%;cursor:pointer;overflow:hidden;-webkit-mask-image:-webkit-radial-gradient(circle,#fff,#000)}.mdl-checkbox__ripple-container .mdl-ripple{background:#3f51b5}fieldset[disabled] .mdl-checkbox .mdl-checkbox__ripple-container,.mdl-checkbox.is-disabled .mdl-checkbox__ripple-container{cursor:auto}fieldset[disabled] .mdl-checkbox .mdl-checkbox__ripple-container .mdl-ripple,.mdl-checkbox.is-disabled .mdl-checkbox__ripple-container .mdl-ripple{background:0 0}.mdl-data-table{position:relative;border:1px solid rgba(0,0,0,.12);border-collapse:collapse;white-space:nowrap;font-size:13px;background-color:#fff}.mdl-data-table thead{padding-bottom:3px}.mdl-data-table thead .mdl-data-table__select{margin-top:0}.mdl-data-table tbody tr{position:relative;height:48px;transition-duration:.28s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-property:background-color}.mdl-data-table tbody tr.is-selected{background-color:#e0e0e0}.mdl-data-table tbody tr:hover{background-color:#eee}.mdl-data-table td{text-align:right}.mdl-data-table th{padding:0 18px 12px 18px;text-align:right}.mdl-data-table td:first-of-type,.mdl-data-table th:first-of-type{padding-left:24px}.mdl-data-table td:last-of-type,.mdl-data-table th:last-of-type{padding-right:24px}.mdl-data-table td{position:relative;height:48px;border-top:1px solid rgba(0,0,0,.12);border-bottom:1px solid rgba(0,0,0,.12);padding:12px 18px;box-sizing:border-box}.mdl-data-table td,.mdl-data-table td .mdl-data-table__select{vertical-align:middle}.mdl-data-table th{position:relative;vertical-align:bottom;text-overflow:ellipsis;font-weight:700;line-height:24px;letter-spacing:0;height:48px;font-size:12px;color:rgba(0,0,0,.54);padding-bottom:8px;box-sizing:border-box}.mdl-data-table th.mdl-data-table__header--sorted-ascending,.mdl-data-table th.mdl-data-table__header--sorted-descending{color:rgba(0,0,0,.87)}.mdl-data-table th.mdl-data-table__header--sorted-ascending:before,.mdl-data-table th.mdl-data-table__header--sorted-descending:before{font-family:'Material Icons';font-weight:400;font-style:normal;line-height:1;letter-spacing:normal;text-transform:none;display:inline-block;word-wrap:normal;-moz-font-feature-settings:'liga';font-feature-settings:'liga';-webkit-font-feature-settings:'liga';-webkit-font-smoothing:antialiased;font-size:16px;content:"\e5d8";margin-right:5px;vertical-align:sub}.mdl-data-table th.mdl-data-table__header--sorted-ascending:hover,.mdl-data-table th.mdl-data-table__header--sorted-descending:hover{cursor:pointer}.mdl-data-table th.mdl-data-table__header--sorted-ascending:hover:before,.mdl-data-table th.mdl-data-table__header--sorted-descending:hover:before{color:rgba(0,0,0,.26)}.mdl-data-table th.mdl-data-table__header--sorted-descending:before{content:"\e5db"}.mdl-data-table__select{width:16px}.mdl-data-table__cell--non-numeric.mdl-data-table__cell--non-numeric{text-align:left}.mdl-dialog{border:none;box-shadow:0 9px 46px 8px rgba(0,0,0,.14),0 11px 15px -7px rgba(0,0,0,.12),0 24px 38px 3px rgba(0,0,0,.2);width:280px}.mdl-dialog__title{padding:24px 24px 0;margin:0;font-size:2.5rem}.mdl-dialog__actions{padding:8px 8px 8px 24px;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:row-reverse;-ms-flex-direction:row-reverse;flex-direction:row-reverse;-webkit-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap}.mdl-dialog__actions>*{margin-right:8px;height:36px}.mdl-dialog__actions>*:first-child{margin-right:0}.mdl-dialog__actions--full-width{padding:0 0 8px}.mdl-dialog__actions--full-width>*{height:48px;-webkit-flex:0 0 100%;-ms-flex:0 0 100%;flex:0 0 100%;padding-right:16px;margin-right:0;text-align:right}.mdl-dialog__content{padding:20px 24px 24px;color:rgba(0,0,0,.54)}.mdl-mega-footer{padding:16px 40px;color:#9e9e9e;background-color:#424242}.mdl-mega-footer--top-section:after,.mdl-mega-footer--middle-section:after,.mdl-mega-footer--bottom-section:after,.mdl-mega-footer__top-section:after,.mdl-mega-footer__middle-section:after,.mdl-mega-footer__bottom-section:after{content:'';display:block;clear:both}.mdl-mega-footer--left-section,.mdl-mega-footer__left-section,.mdl-mega-footer--right-section,.mdl-mega-footer__right-section{margin-bottom:16px}.mdl-mega-footer--right-section a,.mdl-mega-footer__right-section a{display:block;margin-bottom:16px;color:inherit;text-decoration:none}@media screen and (min-width:760px){.mdl-mega-footer--left-section,.mdl-mega-footer__left-section{float:left}.mdl-mega-footer--right-section,.mdl-mega-footer__right-section{float:right}.mdl-mega-footer--right-section a,.mdl-mega-footer__right-section a{display:inline-block;margin-left:16px;line-height:36px;vertical-align:middle}}.mdl-mega-footer--social-btn,.mdl-mega-footer__social-btn{width:36px;height:36px;padding:0;margin:0;background-color:#9e9e9e;border:none}.mdl-mega-footer--drop-down-section,.mdl-mega-footer__drop-down-section{display:block;position:relative}@media screen and (min-width:760px){.mdl-mega-footer--drop-down-section,.mdl-mega-footer__drop-down-section{width:33%}.mdl-mega-footer--drop-down-section:nth-child(1),.mdl-mega-footer--drop-down-section:nth-child(2),.mdl-mega-footer__drop-down-section:nth-child(1),.mdl-mega-footer__drop-down-section:nth-child(2){float:left}.mdl-mega-footer--drop-down-section:nth-child(3),.mdl-mega-footer__drop-down-section:nth-child(3){float:right}.mdl-mega-footer--drop-down-section:nth-child(3):after,.mdl-mega-footer__drop-down-section:nth-child(3):after{clear:right}.mdl-mega-footer--drop-down-section:nth-child(4),.mdl-mega-footer__drop-down-section:nth-child(4){clear:right;float:right}.mdl-mega-footer--middle-section:after,.mdl-mega-footer__middle-section:after{content:'';display:block;clear:both}.mdl-mega-footer--bottom-section,.mdl-mega-footer__bottom-section{padding-top:0}}@media screen and (min-width:1024px){.mdl-mega-footer--drop-down-section,.mdl-mega-footer--drop-down-section:nth-child(3),.mdl-mega-footer--drop-down-section:nth-child(4),.mdl-mega-footer__drop-down-section,.mdl-mega-footer__drop-down-section:nth-child(3),.mdl-mega-footer__drop-down-section:nth-child(4){width:24%;float:left}}.mdl-mega-footer--heading-checkbox,.mdl-mega-footer__heading-checkbox{position:absolute;width:100%;height:55.8px;padding:32px;margin:-16px 0 0;cursor:pointer;z-index:1;opacity:0}.mdl-mega-footer--heading-checkbox+.mdl-mega-footer--heading:after,.mdl-mega-footer--heading-checkbox+.mdl-mega-footer__heading:after,.mdl-mega-footer__heading-checkbox+.mdl-mega-footer--heading:after,.mdl-mega-footer__heading-checkbox+.mdl-mega-footer__heading:after{font-family:'Material Icons';content:'\E5CE'}.mdl-mega-footer--heading-checkbox:checked~.mdl-mega-footer--link-list,.mdl-mega-footer--heading-checkbox:checked~.mdl-mega-footer__link-list,.mdl-mega-footer--heading-checkbox:checked+.mdl-mega-footer--heading+.mdl-mega-footer--link-list,.mdl-mega-footer--heading-checkbox:checked+.mdl-mega-footer__heading+.mdl-mega-footer__link-list,.mdl-mega-footer__heading-checkbox:checked~.mdl-mega-footer--link-list,.mdl-mega-footer__heading-checkbox:checked~.mdl-mega-footer__link-list,.mdl-mega-footer__heading-checkbox:checked+.mdl-mega-footer--heading+.mdl-mega-footer--link-list,.mdl-mega-footer__heading-checkbox:checked+.mdl-mega-footer__heading+.mdl-mega-footer__link-list{display:none}.mdl-mega-footer--heading-checkbox:checked+.mdl-mega-footer--heading:after,.mdl-mega-footer--heading-checkbox:checked+.mdl-mega-footer__heading:after,.mdl-mega-footer__heading-checkbox:checked+.mdl-mega-footer--heading:after,.mdl-mega-footer__heading-checkbox:checked+.mdl-mega-footer__heading:after{font-family:'Material Icons';content:'\E5CF'}.mdl-mega-footer--heading,.mdl-mega-footer__heading{position:relative;width:100%;padding-right:39.8px;margin-bottom:16px;box-sizing:border-box;font-size:14px;line-height:23.8px;font-weight:500;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;color:#e0e0e0}.mdl-mega-footer--heading:after,.mdl-mega-footer__heading:after{content:'';position:absolute;top:0;right:0;display:block;width:23.8px;height:23.8px;background-size:cover}.mdl-mega-footer--link-list,.mdl-mega-footer__link-list{list-style:none;padding:0;margin:0 0 32px}.mdl-mega-footer--link-list:after,.mdl-mega-footer__link-list:after{clear:both;display:block;content:''}.mdl-mega-footer--link-list li,.mdl-mega-footer__link-list li{font-size:14px;font-weight:400;letter-spacing:0;line-height:20px}.mdl-mega-footer--link-list a,.mdl-mega-footer__link-list a{color:inherit;text-decoration:none;white-space:nowrap}@media screen and (min-width:760px){.mdl-mega-footer--heading-checkbox,.mdl-mega-footer__heading-checkbox{display:none}.mdl-mega-footer--heading-checkbox+.mdl-mega-footer--heading:after,.mdl-mega-footer--heading-checkbox+.mdl-mega-footer__heading:after,.mdl-mega-footer__heading-checkbox+.mdl-mega-footer--heading:after,.mdl-mega-footer__heading-checkbox+.mdl-mega-footer__heading:after{content:''}.mdl-mega-footer--heading-checkbox:checked~.mdl-mega-footer--link-list,.mdl-mega-footer--heading-checkbox:checked~.mdl-mega-footer__link-list,.mdl-mega-footer--heading-checkbox:checked+.mdl-mega-footer__heading+.mdl-mega-footer__link-list,.mdl-mega-footer--heading-checkbox:checked+.mdl-mega-footer--heading+.mdl-mega-footer--link-list,.mdl-mega-footer__heading-checkbox:checked~.mdl-mega-footer--link-list,.mdl-mega-footer__heading-checkbox:checked~.mdl-mega-footer__link-list,.mdl-mega-footer__heading-checkbox:checked+.mdl-mega-footer__heading+.mdl-mega-footer__link-list,.mdl-mega-footer__heading-checkbox:checked+.mdl-mega-footer--heading+.mdl-mega-footer--link-list{display:block}.mdl-mega-footer--heading-checkbox:checked+.mdl-mega-footer--heading:after,.mdl-mega-footer--heading-checkbox:checked+.mdl-mega-footer__heading:after,.mdl-mega-footer__heading-checkbox:checked+.mdl-mega-footer--heading:after,.mdl-mega-footer__heading-checkbox:checked+.mdl-mega-footer__heading:after{content:''}}.mdl-mega-footer--bottom-section,.mdl-mega-footer__bottom-section{padding-top:16px;margin-bottom:16px}.mdl-logo{margin-bottom:16px;color:#fff}.mdl-mega-footer--bottom-section .mdl-mega-footer--link-list li,.mdl-mega-footer__bottom-section .mdl-mega-footer__link-list li{float:left;margin-bottom:0;margin-right:16px}@media screen and (min-width:760px){.mdl-logo{float:left;margin-bottom:0;margin-right:16px}}.mdl-mini-footer{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-flow:row wrap;-ms-flex-flow:row wrap;flex-flow:row wrap;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;padding:32px 16px;color:#9e9e9e;background-color:#424242}.mdl-mini-footer:after{content:'';display:block}.mdl-mini-footer .mdl-logo{line-height:36px}.mdl-mini-footer--link-list,.mdl-mini-footer__link-list{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-flow:row nowrap;-ms-flex-flow:row nowrap;flex-flow:row nowrap;list-style:none;margin:0;padding:0}.mdl-mini-footer--link-list li,.mdl-mini-footer__link-list li{margin-bottom:0;margin-right:16px}@media screen and (min-width:760px){.mdl-mini-footer--link-list li,.mdl-mini-footer__link-list li{line-height:36px}}.mdl-mini-footer--link-list a,.mdl-mini-footer__link-list a{color:inherit;text-decoration:none;white-space:nowrap}.mdl-mini-footer--left-section,.mdl-mini-footer__left-section{display:inline-block;-webkit-order:0;-ms-flex-order:0;order:0}.mdl-mini-footer--right-section,.mdl-mini-footer__right-section{display:inline-block;-webkit-order:1;-ms-flex-order:1;order:1}.mdl-mini-footer--social-btn,.mdl-mini-footer__social-btn{width:36px;height:36px;padding:0;margin:0;background-color:#9e9e9e;border:none}.mdl-icon-toggle{position:relative;z-index:1;vertical-align:middle;display:inline-block;height:32px;margin:0;padding:0}.mdl-icon-toggle__input{line-height:32px}.mdl-icon-toggle.is-upgraded .mdl-icon-toggle__input{position:absolute;width:0;height:0;margin:0;padding:0;opacity:0;-ms-appearance:none;-moz-appearance:none;-webkit-appearance:none;appearance:none;border:none}.mdl-icon-toggle__label{display:inline-block;position:relative;cursor:pointer;height:32px;width:32px;min-width:32px;color:#616161;border-radius:50%;padding:0;margin-left:0;margin-right:0;text-align:center;background-color:transparent;will-change:background-color;transition:background-color .2s cubic-bezier(.4,0,.2,1),color .2s cubic-bezier(.4,0,.2,1)}.mdl-icon-toggle__label.material-icons{line-height:32px;font-size:24px}.mdl-icon-toggle.is-checked .mdl-icon-toggle__label{color:#3f51b5}.mdl-icon-toggle.is-disabled .mdl-icon-toggle__label{color:rgba(0,0,0,.26);cursor:auto;transition:none}.mdl-icon-toggle.is-focused .mdl-icon-toggle__label{background-color:rgba(0,0,0,.12)}.mdl-icon-toggle.is-focused.is-checked .mdl-icon-toggle__label{background-color:rgba(63,81,181,.26)}.mdl-icon-toggle__ripple-container{position:absolute;z-index:2;top:-2px;left:-2px;box-sizing:border-box;width:36px;height:36px;border-radius:50%;cursor:pointer;overflow:hidden;-webkit-mask-image:-webkit-radial-gradient(circle,#fff,#000)}.mdl-icon-toggle__ripple-container .mdl-ripple{background:#616161}.mdl-icon-toggle.is-disabled .mdl-icon-toggle__ripple-container{cursor:auto}.mdl-icon-toggle.is-disabled .mdl-icon-toggle__ripple-container .mdl-ripple{background:0 0}.mdl-list{display:block;padding:8px 0;list-style:none}.mdl-list__item{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:16px;font-weight:400;letter-spacing:.04em;line-height:1;min-height:48px;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;padding:16px;cursor:default;color:rgba(0,0,0,.87);overflow:hidden}.mdl-list__item,.mdl-list__item .mdl-list__item-primary-content{box-sizing:border-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-align-items:center;-ms-flex-align:center;align-items:center}.mdl-list__item .mdl-list__item-primary-content{-webkit-order:0;-ms-flex-order:0;order:0;-webkit-flex-grow:2;-ms-flex-positive:2;flex-grow:2;text-decoration:none}.mdl-list__item .mdl-list__item-primary-content .mdl-list__item-icon{margin-right:32px}.mdl-list__item .mdl-list__item-primary-content .mdl-list__item-avatar{margin-right:16px}.mdl-list__item .mdl-list__item-secondary-content{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-flow:column;-ms-flex-flow:column;flex-flow:column;-webkit-align-items:flex-end;-ms-flex-align:end;align-items:flex-end;margin-left:16px}.mdl-list__item .mdl-list__item-secondary-content .mdl-list__item-secondary-action label{display:inline}.mdl-list__item .mdl-list__item-secondary-content .mdl-list__item-secondary-info{font-size:12px;font-weight:400;line-height:1;letter-spacing:0;color:rgba(0,0,0,.54)}.mdl-list__item .mdl-list__item-secondary-content .mdl-list__item-sub-header{padding:0 0 0 16px}.mdl-list__item-icon,.mdl-list__item-icon.material-icons{height:24px;width:24px;font-size:24px;box-sizing:border-box;color:#757575}.mdl-list__item-avatar,.mdl-list__item-avatar.material-icons{height:40px;width:40px;box-sizing:border-box;border-radius:50%;background-color:#757575;font-size:40px;color:#fff}.mdl-list__item--two-line{height:72px}.mdl-list__item--two-line .mdl-list__item-primary-content{height:36px;line-height:20px;display:block}.mdl-list__item--two-line .mdl-list__item-primary-content .mdl-list__item-avatar{float:left}.mdl-list__item--two-line .mdl-list__item-primary-content .mdl-list__item-icon{float:left;margin-top:6px}.mdl-list__item--two-line .mdl-list__item-primary-content .mdl-list__item-secondary-content{height:36px}.mdl-list__item--two-line .mdl-list__item-primary-content .mdl-list__item-sub-title{font-size:14px;font-weight:400;letter-spacing:0;line-height:18px;color:rgba(0,0,0,.54);display:block;padding:0}.mdl-list__item--three-line{height:88px}.mdl-list__item--three-line .mdl-list__item-primary-content{height:52px;line-height:20px;display:block}.mdl-list__item--three-line .mdl-list__item-primary-content .mdl-list__item-avatar,.mdl-list__item--three-line .mdl-list__item-primary-content .mdl-list__item-icon{float:left}.mdl-list__item--three-line .mdl-list__item-secondary-content{height:52px}.mdl-list__item--three-line .mdl-list__item-text-body{font-size:14px;font-weight:400;letter-spacing:0;line-height:18px;height:52px;color:rgba(0,0,0,.54);display:block;padding:0}.mdl-menu__container{display:block;margin:0;padding:0;border:none;position:absolute;overflow:visible;height:0;width:0;visibility:hidden;z-index:-1}.mdl-menu__container.is-visible,.mdl-menu__container.is-animating{z-index:999;visibility:visible}.mdl-menu__outline{display:block;background:#fff;margin:0;padding:0;border:none;border-radius:2px;position:absolute;top:0;left:0;overflow:hidden;opacity:0;-webkit-transform:scale(0);transform:scale(0);-webkit-transform-origin:0 0;transform-origin:0 0;box-shadow:0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);will-change:transform;transition:transform .3s cubic-bezier(.4,0,.2,1),opacity .2s cubic-bezier(.4,0,.2,1);transition:transform .3s cubic-bezier(.4,0,.2,1),opacity .2s cubic-bezier(.4,0,.2,1),-webkit-transform .3s cubic-bezier(.4,0,.2,1);z-index:-1}.mdl-menu__container.is-visible .mdl-menu__outline{opacity:1;-webkit-transform:scale(1);transform:scale(1);z-index:999}.mdl-menu__outline.mdl-menu--bottom-right{-webkit-transform-origin:100% 0;transform-origin:100% 0}.mdl-menu__outline.mdl-menu--top-left{-webkit-transform-origin:0 100%;transform-origin:0 100%}.mdl-menu__outline.mdl-menu--top-right{-webkit-transform-origin:100% 100%;transform-origin:100% 100%}.mdl-menu{position:absolute;list-style:none;top:0;left:0;height:auto;width:auto;min-width:124px;padding:8px 0;margin:0;opacity:0;clip:rect(0 0 0 0);z-index:-1}.mdl-menu__container.is-visible .mdl-menu{opacity:1;z-index:999}.mdl-menu.is-animating{transition:opacity .2s cubic-bezier(.4,0,.2,1),clip .3s cubic-bezier(.4,0,.2,1)}.mdl-menu.mdl-menu--bottom-right{left:auto;right:0}.mdl-menu.mdl-menu--top-left{top:auto;bottom:0}.mdl-menu.mdl-menu--top-right{top:auto;left:auto;bottom:0;right:0}.mdl-menu.mdl-menu--unaligned{top:auto;left:auto}.mdl-menu__item{display:block;border:none;color:rgba(0,0,0,.87);background-color:transparent;text-align:left;margin:0;padding:0 16px;outline-color:#bdbdbd;position:relative;overflow:hidden;font-size:14px;font-weight:400;letter-spacing:0;text-decoration:none;cursor:pointer;height:48px;line-height:48px;white-space:nowrap;opacity:0;transition:opacity .2s cubic-bezier(.4,0,.2,1);-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.mdl-menu__container.is-visible .mdl-menu__item{opacity:1}.mdl-menu__item::-moz-focus-inner{border:0}.mdl-menu__item--full-bleed-divider{border-bottom:1px solid rgba(0,0,0,.12)}.mdl-menu__item[disabled],.mdl-menu__item[data-mdl-disabled]{color:#bdbdbd;background-color:transparent;cursor:auto}.mdl-menu__item[disabled]:hover,.mdl-menu__item[data-mdl-disabled]:hover{background-color:transparent}.mdl-menu__item[disabled]:focus,.mdl-menu__item[data-mdl-disabled]:focus{background-color:transparent}.mdl-menu__item[disabled] .mdl-ripple,.mdl-menu__item[data-mdl-disabled] .mdl-ripple{background:0 0}.mdl-menu__item:hover{background-color:#eee}.mdl-menu__item:focus{outline:none;background-color:#eee}.mdl-menu__item:active{background-color:#e0e0e0}.mdl-menu__item--ripple-container{display:block;height:100%;left:0;position:absolute;top:0;width:100%;z-index:0;overflow:hidden}.mdl-progress{display:block;position:relative;height:4px;width:500px;max-width:100%}.mdl-progress>.bar{display:block;position:absolute;top:0;bottom:0;width:0%;transition:width .2s cubic-bezier(.4,0,.2,1)}.mdl-progress>.progressbar{background-color:#3f51b5;z-index:1;left:0}.mdl-progress>.bufferbar{background-image:linear-gradient(to right,rgba(255,255,255,.7),rgba(255,255,255,.7)),linear-gradient(to right,#3f51b5 ,#3f51b5);z-index:0;left:0}.mdl-progress>.auxbar{right:0}@supports (-webkit-appearance:none){.mdl-progress:not(.mdl-progress--indeterminate):not(.mdl-progress--indeterminate)>.auxbar,.mdl-progress:not(.mdl-progress__indeterminate):not(.mdl-progress__indeterminate)>.auxbar{background-image:linear-gradient(to right,rgba(255,255,255,.7),rgba(255,255,255,.7)),linear-gradient(to right,#3f51b5 ,#3f51b5);-webkit-mask:url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+Cjxzdmcgd2lkdGg9IjEyIiBoZWlnaHQ9IjQiIHZpZXdQb3J0PSIwIDAgMTIgNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxlbGxpcHNlIGN4PSIyIiBjeT0iMiIgcng9IjIiIHJ5PSIyIj4KICAgIDxhbmltYXRlIGF0dHJpYnV0ZU5hbWU9ImN4IiBmcm9tPSIyIiB0bz0iLTEwIiBkdXI9IjAuNnMiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIiAvPgogIDwvZWxsaXBzZT4KICA8ZWxsaXBzZSBjeD0iMTQiIGN5PSIyIiByeD0iMiIgcnk9IjIiIGNsYXNzPSJsb2FkZXIiPgogICAgPGFuaW1hdGUgYXR0cmlidXRlTmFtZT0iY3giIGZyb209IjE0IiB0bz0iMiIgZHVyPSIwLjZzIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSIgLz4KICA8L2VsbGlwc2U+Cjwvc3ZnPgo=");mask:url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+Cjxzdmcgd2lkdGg9IjEyIiBoZWlnaHQ9IjQiIHZpZXdQb3J0PSIwIDAgMTIgNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxlbGxpcHNlIGN4PSIyIiBjeT0iMiIgcng9IjIiIHJ5PSIyIj4KICAgIDxhbmltYXRlIGF0dHJpYnV0ZU5hbWU9ImN4IiBmcm9tPSIyIiB0bz0iLTEwIiBkdXI9IjAuNnMiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIiAvPgogIDwvZWxsaXBzZT4KICA8ZWxsaXBzZSBjeD0iMTQiIGN5PSIyIiByeD0iMiIgcnk9IjIiIGNsYXNzPSJsb2FkZXIiPgogICAgPGFuaW1hdGUgYXR0cmlidXRlTmFtZT0iY3giIGZyb209IjE0IiB0bz0iMiIgZHVyPSIwLjZzIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSIgLz4KICA8L2VsbGlwc2U+Cjwvc3ZnPgo=")}}.mdl-progress:not(.mdl-progress--indeterminate)>.auxbar,.mdl-progress:not(.mdl-progress__indeterminate)>.auxbar{background-image:linear-gradient(to right,rgba(255,255,255,.9),rgba(255,255,255,.9)),linear-gradient(to right,#3f51b5 ,#3f51b5)}.mdl-progress.mdl-progress--indeterminate>.bar1,.mdl-progress.mdl-progress__indeterminate>.bar1{-webkit-animation-name:indeterminate1;animation-name:indeterminate1}.mdl-progress.mdl-progress--indeterminate>.bar1,.mdl-progress.mdl-progress__indeterminate>.bar1,.mdl-progress.mdl-progress--indeterminate>.bar3,.mdl-progress.mdl-progress__indeterminate>.bar3{background-color:#3f51b5;-webkit-animation-duration:2s;animation-duration:2s;-webkit-animation-iteration-count:infinite;animation-iteration-count:infinite;-webkit-animation-timing-function:linear;animation-timing-function:linear}.mdl-progress.mdl-progress--indeterminate>.bar3,.mdl-progress.mdl-progress__indeterminate>.bar3{background-image:none;-webkit-animation-name:indeterminate2;animation-name:indeterminate2}@-webkit-keyframes indeterminate1{0%{left:0%;width:0%}50%{left:25%;width:75%}75%{left:100%;width:0%}}@keyframes indeterminate1{0%{left:0%;width:0%}50%{left:25%;width:75%}75%{left:100%;width:0%}}@-webkit-keyframes indeterminate2{0%,50%{left:0%;width:0%}75%{left:0%;width:25%}100%{left:100%;width:0%}}@keyframes indeterminate2{0%,50%{left:0%;width:0%}75%{left:0%;width:25%}100%{left:100%;width:0%}}.mdl-navigation{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;box-sizing:border-box}.mdl-navigation__link{color:#424242;text-decoration:none;margin:0;font-size:14px;font-weight:400;line-height:24px;letter-spacing:0;opacity:.87}.mdl-navigation__link .material-icons{vertical-align:middle}.mdl-layout{width:100%;height:100%;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;overflow-y:auto;overflow-x:hidden;position:relative;-webkit-overflow-scrolling:touch}.mdl-layout.is-small-screen .mdl-layout--large-screen-only{display:none}.mdl-layout:not(.is-small-screen) .mdl-layout--small-screen-only{display:none}.mdl-layout__container{position:absolute;width:100%;height:100%}.mdl-layout__title,.mdl-layout-title{display:block;position:relative;font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:20px;line-height:1;letter-spacing:.02em;font-weight:400;box-sizing:border-box}.mdl-layout-spacer{-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1}.mdl-layout__drawer{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;width:240px;height:100%;max-height:100%;position:absolute;top:0;left:0;box-shadow:0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);box-sizing:border-box;border-right:1px solid #e0e0e0;background:#fafafa;-webkit-transform:translateX(-250px);transform:translateX(-250px);-webkit-transform-style:preserve-3d;transform-style:preserve-3d;will-change:transform;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-property:transform;transition-property:transform,-webkit-transform;color:#424242;overflow:visible;overflow-y:auto;z-index:5}.mdl-layout__drawer.is-visible{-webkit-transform:translateX(0);transform:translateX(0)}.mdl-layout__drawer.is-visible~.mdl-layout__content.mdl-layout__content{overflow:hidden}.mdl-layout__drawer>*{-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0}.mdl-layout__drawer>.mdl-layout__title,.mdl-layout__drawer>.mdl-layout-title{line-height:64px;padding-left:40px}@media screen and (max-width:1024px){.mdl-layout__drawer>.mdl-layout__title,.mdl-layout__drawer>.mdl-layout-title{line-height:56px;padding-left:16px}}.mdl-layout__drawer .mdl-navigation{-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;-webkit-align-items:stretch;-ms-flex-align:stretch;-ms-grid-row-align:stretch;align-items:stretch;padding-top:16px}.mdl-layout__drawer .mdl-navigation .mdl-navigation__link{display:block;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0;padding:16px 40px;margin:0;color:#757575}@media screen and (max-width:1024px){.mdl-layout__drawer .mdl-navigation .mdl-navigation__link{padding:16px}}.mdl-layout__drawer .mdl-navigation .mdl-navigation__link:hover{background-color:#e0e0e0}.mdl-layout__drawer .mdl-navigation .mdl-navigation__link--current{background-color:#000;color:#e0e0e0}@media screen and (min-width:1025px){.mdl-layout--fixed-drawer>.mdl-layout__drawer{-webkit-transform:translateX(0);transform:translateX(0)}}.mdl-layout__drawer-button{display:block;position:absolute;height:48px;width:48px;border:0;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0;overflow:hidden;text-align:center;cursor:pointer;font-size:26px;line-height:50px;font-family:Helvetica,Arial,sans-serif;margin:10px 12px;top:0;left:0;color:#fff;z-index:4}.mdl-layout__header .mdl-layout__drawer-button{position:absolute;color:#fff;background-color:inherit}@media screen and (max-width:1024px){.mdl-layout__header .mdl-layout__drawer-button{margin:4px}}@media screen and (max-width:1024px){.mdl-layout__drawer-button{margin:4px;color:rgba(0,0,0,.5)}}@media screen and (min-width:1025px){.mdl-layout--fixed-drawer>.mdl-layout__drawer-button,.mdl-layout--no-desktop-drawer-button .mdl-layout__drawer-button{display:none}}.mdl-layout--no-drawer-button .mdl-layout__drawer-button{display:none}.mdl-layout__header{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;-webkit-justify-content:flex-start;-ms-flex-pack:start;justify-content:flex-start;box-sizing:border-box;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0;width:100%;margin:0;padding:0;border:none;min-height:64px;max-height:1000px;z-index:3;background-color:#3f51b5;color:#fff;box-shadow:0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-property:max-height,box-shadow}@media screen and (max-width:1024px){.mdl-layout__header{min-height:56px}}.mdl-layout--fixed-drawer.is-upgraded:not(.is-small-screen)>.mdl-layout__header{margin-left:240px;width:calc(100% - 240px)}@media screen and (min-width:1025px){.mdl-layout--fixed-drawer>.mdl-layout__header .mdl-layout__header-row{padding-left:40px}}.mdl-layout__header>.mdl-layout-icon{position:absolute;left:40px;top:16px;height:32px;width:32px;overflow:hidden;z-index:3;display:block}@media screen and (max-width:1024px){.mdl-layout__header>.mdl-layout-icon{left:16px;top:12px}}.mdl-layout.has-drawer .mdl-layout__header>.mdl-layout-icon{display:none}.mdl-layout__header.is-compact{max-height:64px}@media screen and (max-width:1024px){.mdl-layout__header.is-compact{max-height:56px}}.mdl-layout__header.is-compact.has-tabs{height:112px}@media screen and (max-width:1024px){.mdl-layout__header.is-compact.has-tabs{min-height:104px}}@media screen and (max-width:1024px){.mdl-layout__header{display:none}.mdl-layout--fixed-header>.mdl-layout__header{display:-webkit-flex;display:-ms-flexbox;display:flex}}.mdl-layout__header--transparent.mdl-layout__header--transparent{background-color:transparent;box-shadow:none}.mdl-layout__header--seamed,.mdl-layout__header--scroll{box-shadow:none}.mdl-layout__header--waterfall{box-shadow:none;overflow:hidden}.mdl-layout__header--waterfall.is-casting-shadow{box-shadow:0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12)}.mdl-layout__header--waterfall.mdl-layout__header--waterfall-hide-top{-webkit-justify-content:flex-end;-ms-flex-pack:end;justify-content:flex-end}.mdl-layout__header-row{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0;box-sizing:border-box;-webkit-align-self:stretch;-ms-flex-item-align:stretch;align-self:stretch;-webkit-align-items:center;-ms-flex-align:center;align-items:center;height:64px;margin:0;padding:0 40px 0 80px}.mdl-layout--no-drawer-button .mdl-layout__header-row{padding-left:40px}@media screen and (min-width:1025px){.mdl-layout--no-desktop-drawer-button .mdl-layout__header-row{padding-left:40px}}@media screen and (max-width:1024px){.mdl-layout__header-row{height:56px;padding:0 16px 0 72px}.mdl-layout--no-drawer-button .mdl-layout__header-row{padding-left:16px}}.mdl-layout__header-row>*{-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0}.mdl-layout__header--scroll .mdl-layout__header-row{width:100%}.mdl-layout__header-row .mdl-navigation{margin:0;padding:0;height:64px;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-align-items:center;-ms-flex-align:center;-ms-grid-row-align:center;align-items:center}@media screen and (max-width:1024px){.mdl-layout__header-row .mdl-navigation{height:56px}}.mdl-layout__header-row .mdl-navigation__link{display:block;color:#fff;line-height:64px;padding:0 24px}@media screen and (max-width:1024px){.mdl-layout__header-row .mdl-navigation__link{line-height:56px;padding:0 16px}}.mdl-layout__obfuscator{background-color:transparent;position:absolute;top:0;left:0;height:100%;width:100%;z-index:4;visibility:hidden;transition-property:background-color;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.mdl-layout__obfuscator.is-visible{background-color:rgba(0,0,0,.5);visibility:visible}@supports (pointer-events:auto){.mdl-layout__obfuscator{background-color:rgba(0,0,0,.5);opacity:0;transition-property:opacity;visibility:visible;pointer-events:none}.mdl-layout__obfuscator.is-visible{pointer-events:auto;opacity:1}}.mdl-layout__content{-ms-flex:0 1 auto;position:relative;display:inline-block;overflow-y:auto;overflow-x:hidden;-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1;z-index:1;-webkit-overflow-scrolling:touch}.mdl-layout--fixed-drawer>.mdl-layout__content{margin-left:240px}.mdl-layout__container.has-scrolling-header .mdl-layout__content{overflow:visible}@media screen and (max-width:1024px){.mdl-layout--fixed-drawer>.mdl-layout__content{margin-left:0}.mdl-layout__container.has-scrolling-header .mdl-layout__content{overflow-y:auto;overflow-x:hidden}}.mdl-layout__tab-bar{height:96px;margin:0;width:calc(100% - 112px);padding:0 0 0 56px;display:-webkit-flex;display:-ms-flexbox;display:flex;background-color:#3f51b5;overflow-y:hidden;overflow-x:scroll}.mdl-layout__tab-bar::-webkit-scrollbar{display:none}.mdl-layout--no-drawer-button .mdl-layout__tab-bar{padding-left:16px;width:calc(100% - 32px)}@media screen and (min-width:1025px){.mdl-layout--no-desktop-drawer-button .mdl-layout__tab-bar{padding-left:16px;width:calc(100% - 32px)}}@media screen and (max-width:1024px){.mdl-layout__tab-bar{width:calc(100% - 60px);padding:0 0 0 60px}.mdl-layout--no-drawer-button .mdl-layout__tab-bar{width:calc(100% - 8px);padding-left:4px}}.mdl-layout--fixed-tabs .mdl-layout__tab-bar{padding:0;overflow:hidden;width:100%}.mdl-layout__tab-bar-container{position:relative;height:48px;width:100%;border:none;margin:0;z-index:2;-webkit-flex-grow:0;-ms-flex-positive:0;flex-grow:0;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0;overflow:hidden}.mdl-layout__container>.mdl-layout__tab-bar-container{position:absolute;top:0;left:0}.mdl-layout__tab-bar-button{display:inline-block;position:absolute;top:0;height:48px;width:56px;z-index:4;text-align:center;background-color:#3f51b5;color:transparent;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.mdl-layout--no-desktop-drawer-button .mdl-layout__tab-bar-button,.mdl-layout--no-drawer-button .mdl-layout__tab-bar-button{width:16px}.mdl-layout--no-desktop-drawer-button .mdl-layout__tab-bar-button .material-icons,.mdl-layout--no-drawer-button .mdl-layout__tab-bar-button .material-icons{position:relative;left:-4px}@media screen and (max-width:1024px){.mdl-layout__tab-bar-button{display:none;width:60px}}.mdl-layout--fixed-tabs .mdl-layout__tab-bar-button{display:none}.mdl-layout__tab-bar-button .material-icons{line-height:48px}.mdl-layout__tab-bar-button.is-active{color:#fff}.mdl-layout__tab-bar-left-button{left:0}.mdl-layout__tab-bar-right-button{right:0}.mdl-layout__tab{margin:0;border:none;padding:0 24px;float:left;position:relative;display:block;-webkit-flex-grow:0;-ms-flex-positive:0;flex-grow:0;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0;text-decoration:none;height:48px;line-height:48px;text-align:center;font-weight:500;font-size:14px;text-transform:uppercase;color:rgba(255,255,255,.6);overflow:hidden}@media screen and (max-width:1024px){.mdl-layout__tab{padding:0 12px}}.mdl-layout--fixed-tabs .mdl-layout__tab{float:none;-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1;padding:0}.mdl-layout.is-upgraded .mdl-layout__tab.is-active{color:#fff}.mdl-layout.is-upgraded .mdl-layout__tab.is-active::after{height:2px;width:100%;display:block;content:" ";bottom:0;left:0;position:absolute;background:#ff4081;-webkit-animation:border-expand .2s cubic-bezier(.4,0,.4,1).01s alternate forwards;animation:border-expand .2s cubic-bezier(.4,0,.4,1).01s alternate forwards;transition:all 1s cubic-bezier(.4,0,1,1)}.mdl-layout__tab .mdl-layout__tab-ripple-container{display:block;position:absolute;height:100%;width:100%;left:0;top:0;z-index:1;overflow:hidden}.mdl-layout__tab .mdl-layout__tab-ripple-container .mdl-ripple{background-color:#fff}.mdl-layout__tab-panel{display:block}.mdl-layout.is-upgraded .mdl-layout__tab-panel{display:none}.mdl-layout.is-upgraded .mdl-layout__tab-panel.is-active{display:block}.mdl-radio{position:relative;font-size:16px;line-height:24px;display:inline-block;box-sizing:border-box;margin:0;padding-left:0}.mdl-radio.is-upgraded{padding-left:24px}.mdl-radio__button{line-height:24px}.mdl-radio.is-upgraded .mdl-radio__button{position:absolute;width:0;height:0;margin:0;padding:0;opacity:0;-ms-appearance:none;-moz-appearance:none;-webkit-appearance:none;appearance:none;border:none}.mdl-radio__outer-circle{position:absolute;top:4px;left:0;display:inline-block;box-sizing:border-box;width:16px;height:16px;margin:0;cursor:pointer;border:2px solid rgba(0,0,0,.54);border-radius:50%;z-index:2}.mdl-radio.is-checked .mdl-radio__outer-circle{border:2px solid #3f51b5}.mdl-radio__outer-circle fieldset[disabled] .mdl-radio,.mdl-radio.is-disabled .mdl-radio__outer-circle{border:2px solid rgba(0,0,0,.26);cursor:auto}.mdl-radio__inner-circle{position:absolute;z-index:1;margin:0;top:8px;left:4px;box-sizing:border-box;width:8px;height:8px;cursor:pointer;transition-duration:.28s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-property:transform;transition-property:transform,-webkit-transform;-webkit-transform:scale3d(0,0,0);transform:scale3d(0,0,0);border-radius:50%;background:#3f51b5}.mdl-radio.is-checked .mdl-radio__inner-circle{-webkit-transform:scale3d(1,1,1);transform:scale3d(1,1,1)}fieldset[disabled] .mdl-radio .mdl-radio__inner-circle,.mdl-radio.is-disabled .mdl-radio__inner-circle{background:rgba(0,0,0,.26);cursor:auto}.mdl-radio.is-focused .mdl-radio__inner-circle{box-shadow:0 0 0 10px rgba(0,0,0,.1)}.mdl-radio__label{cursor:pointer}fieldset[disabled] .mdl-radio .mdl-radio__label,.mdl-radio.is-disabled .mdl-radio__label{color:rgba(0,0,0,.26);cursor:auto}.mdl-radio__ripple-container{position:absolute;z-index:2;top:-9px;left:-13px;box-sizing:border-box;width:42px;height:42px;border-radius:50%;cursor:pointer;overflow:hidden;-webkit-mask-image:-webkit-radial-gradient(circle,#fff,#000)}.mdl-radio__ripple-container .mdl-ripple{background:#3f51b5}fieldset[disabled] .mdl-radio .mdl-radio__ripple-container,.mdl-radio.is-disabled .mdl-radio__ripple-container{cursor:auto}fieldset[disabled] .mdl-radio .mdl-radio__ripple-container .mdl-ripple,.mdl-radio.is-disabled .mdl-radio__ripple-container .mdl-ripple{background:0 0}_:-ms-input-placeholder,:root .mdl-slider.mdl-slider.is-upgraded{-ms-appearance:none;height:32px;margin:0}.mdl-slider{width:calc(100% - 40px);margin:0 20px}.mdl-slider.is-upgraded{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:2px;background:0 0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;outline:0;padding:0;color:#3f51b5;-webkit-align-self:center;-ms-flex-item-align:center;align-self:center;z-index:1;cursor:pointer}.mdl-slider.is-upgraded::-moz-focus-outer{border:0}.mdl-slider.is-upgraded::-ms-tooltip{display:none}.mdl-slider.is-upgraded::-webkit-slider-runnable-track{background:0 0}.mdl-slider.is-upgraded::-moz-range-track{background:0 0;border:none}.mdl-slider.is-upgraded::-ms-track{background:0 0;color:transparent;height:2px;width:100%;border:none}.mdl-slider.is-upgraded::-ms-fill-lower{padding:0;background:linear-gradient(to right,transparent,transparent 16px,#3f51b5 16px,#3f51b5 0)}.mdl-slider.is-upgraded::-ms-fill-upper{padding:0;background:linear-gradient(to left,transparent,transparent 16px,rgba(0,0,0,.26)16px,rgba(0,0,0,.26)0)}.mdl-slider.is-upgraded::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;box-sizing:border-box;border-radius:50%;background:#3f51b5;border:none;transition:transform .18s cubic-bezier(.4,0,.2,1),border .18s cubic-bezier(.4,0,.2,1),box-shadow .18s cubic-bezier(.4,0,.2,1),background .28s cubic-bezier(.4,0,.2,1);transition:transform .18s cubic-bezier(.4,0,.2,1),border .18s cubic-bezier(.4,0,.2,1),box-shadow .18s cubic-bezier(.4,0,.2,1),background .28s cubic-bezier(.4,0,.2,1),-webkit-transform .18s cubic-bezier(.4,0,.2,1)}.mdl-slider.is-upgraded::-moz-range-thumb{-moz-appearance:none;width:12px;height:12px;box-sizing:border-box;border-radius:50%;background-image:none;background:#3f51b5;border:none}.mdl-slider.is-upgraded:focus:not(:active)::-webkit-slider-thumb{box-shadow:0 0 0 10px rgba(63,81,181,.26)}.mdl-slider.is-upgraded:focus:not(:active)::-moz-range-thumb{box-shadow:0 0 0 10px rgba(63,81,181,.26)}.mdl-slider.is-upgraded:active::-webkit-slider-thumb{background-image:none;background:#3f51b5;-webkit-transform:scale(1.5);transform:scale(1.5)}.mdl-slider.is-upgraded:active::-moz-range-thumb{background-image:none;background:#3f51b5;transform:scale(1.5)}.mdl-slider.is-upgraded::-ms-thumb{width:32px;height:32px;border:none;border-radius:50%;background:#3f51b5;transform:scale(.375);transition:transform .18s cubic-bezier(.4,0,.2,1),background .28s cubic-bezier(.4,0,.2,1);transition:transform .18s cubic-bezier(.4,0,.2,1),background .28s cubic-bezier(.4,0,.2,1),-webkit-transform .18s cubic-bezier(.4,0,.2,1)}.mdl-slider.is-upgraded:focus:not(:active)::-ms-thumb{background:radial-gradient(circle closest-side,#3f51b5 0%,#3f51b5 37.5%,rgba(63,81,181,.26)37.5%,rgba(63,81,181,.26)100%);transform:scale(1)}.mdl-slider.is-upgraded:active::-ms-thumb{background:#3f51b5;transform:scale(.5625)}.mdl-slider.is-upgraded.is-lowest-value::-webkit-slider-thumb{border:2px solid rgba(0,0,0,.26);background:0 0}.mdl-slider.is-upgraded.is-lowest-value::-moz-range-thumb{border:2px solid rgba(0,0,0,.26);background:0 0}.mdl-slider.is-upgraded.is-lowest-value+.mdl-slider__background-flex>.mdl-slider__background-upper{left:6px}.mdl-slider.is-upgraded.is-lowest-value:focus:not(:active)::-webkit-slider-thumb{box-shadow:0 0 0 10px rgba(0,0,0,.12);background:rgba(0,0,0,.12)}.mdl-slider.is-upgraded.is-lowest-value:focus:not(:active)::-moz-range-thumb{box-shadow:0 0 0 10px rgba(0,0,0,.12);background:rgba(0,0,0,.12)}.mdl-slider.is-upgraded.is-lowest-value:active::-webkit-slider-thumb{border:1.6px solid rgba(0,0,0,.26);-webkit-transform:scale(1.5);transform:scale(1.5)}.mdl-slider.is-upgraded.is-lowest-value:active+.mdl-slider__background-flex>.mdl-slider__background-upper{left:9px}.mdl-slider.is-upgraded.is-lowest-value:active::-moz-range-thumb{border:1.5px solid rgba(0,0,0,.26);transform:scale(1.5)}.mdl-slider.is-upgraded.is-lowest-value::-ms-thumb{background:radial-gradient(circle closest-side,transparent 0%,transparent 66.67%,rgba(0,0,0,.26)66.67%,rgba(0,0,0,.26)100%)}.mdl-slider.is-upgraded.is-lowest-value:focus:not(:active)::-ms-thumb{background:radial-gradient(circle closest-side,rgba(0,0,0,.12)0%,rgba(0,0,0,.12)25%,rgba(0,0,0,.26)25%,rgba(0,0,0,.26)37.5%,rgba(0,0,0,.12)37.5%,rgba(0,0,0,.12)100%);transform:scale(1)}.mdl-slider.is-upgraded.is-lowest-value:active::-ms-thumb{transform:scale(.5625);background:radial-gradient(circle closest-side,transparent 0%,transparent 77.78%,rgba(0,0,0,.26)77.78%,rgba(0,0,0,.26)100%)}.mdl-slider.is-upgraded.is-lowest-value::-ms-fill-lower{background:0 0}.mdl-slider.is-upgraded.is-lowest-value::-ms-fill-upper{margin-left:6px}.mdl-slider.is-upgraded.is-lowest-value:active::-ms-fill-upper{margin-left:9px}.mdl-slider.is-upgraded:disabled:focus::-webkit-slider-thumb,.mdl-slider.is-upgraded:disabled:active::-webkit-slider-thumb,.mdl-slider.is-upgraded:disabled::-webkit-slider-thumb{-webkit-transform:scale(.667);transform:scale(.667);background:rgba(0,0,0,.26)}.mdl-slider.is-upgraded:disabled:focus::-moz-range-thumb,.mdl-slider.is-upgraded:disabled:active::-moz-range-thumb,.mdl-slider.is-upgraded:disabled::-moz-range-thumb{transform:scale(.667);background:rgba(0,0,0,.26)}.mdl-slider.is-upgraded:disabled+.mdl-slider__background-flex>.mdl-slider__background-lower{background-color:rgba(0,0,0,.26);left:-6px}.mdl-slider.is-upgraded:disabled+.mdl-slider__background-flex>.mdl-slider__background-upper{left:6px}.mdl-slider.is-upgraded.is-lowest-value:disabled:focus::-webkit-slider-thumb,.mdl-slider.is-upgraded.is-lowest-value:disabled:active::-webkit-slider-thumb,.mdl-slider.is-upgraded.is-lowest-value:disabled::-webkit-slider-thumb{border:3px solid rgba(0,0,0,.26);background:0 0;-webkit-transform:scale(.667);transform:scale(.667)}.mdl-slider.is-upgraded.is-lowest-value:disabled:focus::-moz-range-thumb,.mdl-slider.is-upgraded.is-lowest-value:disabled:active::-moz-range-thumb,.mdl-slider.is-upgraded.is-lowest-value:disabled::-moz-range-thumb{border:3px solid rgba(0,0,0,.26);background:0 0;transform:scale(.667)}.mdl-slider.is-upgraded.is-lowest-value:disabled:active+.mdl-slider__background-flex>.mdl-slider__background-upper{left:6px}.mdl-slider.is-upgraded:disabled:focus::-ms-thumb,.mdl-slider.is-upgraded:disabled:active::-ms-thumb,.mdl-slider.is-upgraded:disabled::-ms-thumb{transform:scale(.25);background:rgba(0,0,0,.26)}.mdl-slider.is-upgraded.is-lowest-value:disabled:focus::-ms-thumb,.mdl-slider.is-upgraded.is-lowest-value:disabled:active::-ms-thumb,.mdl-slider.is-upgraded.is-lowest-value:disabled::-ms-thumb{transform:scale(.25);background:radial-gradient(circle closest-side,transparent 0%,transparent 50%,rgba(0,0,0,.26)50%,rgba(0,0,0,.26)100%)}.mdl-slider.is-upgraded:disabled::-ms-fill-lower{margin-right:6px;background:linear-gradient(to right,transparent,transparent 25px,rgba(0,0,0,.26)25px,rgba(0,0,0,.26)0)}.mdl-slider.is-upgraded:disabled::-ms-fill-upper{margin-left:6px}.mdl-slider.is-upgraded.is-lowest-value:disabled:active::-ms-fill-upper{margin-left:6px}.mdl-slider__ie-container{height:18px;overflow:visible;border:none;margin:none;padding:none}.mdl-slider__container{height:18px;position:relative;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row}.mdl-slider__container,.mdl-slider__background-flex{background:0 0;display:-webkit-flex;display:-ms-flexbox;display:flex}.mdl-slider__background-flex{position:absolute;height:2px;width:calc(100% - 52px);top:50%;left:0;margin:0 26px;overflow:hidden;border:0;padding:0;-webkit-transform:translate(0,-1px);transform:translate(0,-1px)}.mdl-slider__background-lower{background:#3f51b5}.mdl-slider__background-lower,.mdl-slider__background-upper{-webkit-flex:0;-ms-flex:0;flex:0;position:relative;border:0;padding:0}.mdl-slider__background-upper{background:rgba(0,0,0,.26);transition:left .18s cubic-bezier(.4,0,.2,1)}.mdl-snackbar{position:fixed;bottom:0;left:50%;cursor:default;background-color:#323232;z-index:3;display:block;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;font-family:"Roboto","Helvetica","Arial",sans-serif;will-change:transform;-webkit-transform:translate(0,80px);transform:translate(0,80px);transition:transform .25s cubic-bezier(.4,0,1,1);transition:transform .25s cubic-bezier(.4,0,1,1),-webkit-transform .25s cubic-bezier(.4,0,1,1);pointer-events:none}@media (max-width:479px){.mdl-snackbar{width:100%;left:0;min-height:48px;max-height:80px}}@media (min-width:480px){.mdl-snackbar{min-width:288px;max-width:568px;border-radius:2px;-webkit-transform:translate(-50%,80px);transform:translate(-50%,80px)}}.mdl-snackbar--active{-webkit-transform:translate(0,0);transform:translate(0,0);pointer-events:auto;transition:transform .25s cubic-bezier(0,0,.2,1);transition:transform .25s cubic-bezier(0,0,.2,1),-webkit-transform .25s cubic-bezier(0,0,.2,1)}@media (min-width:480px){.mdl-snackbar--active{-webkit-transform:translate(-50%,0);transform:translate(-50%,0)}}.mdl-snackbar__text{padding:14px 12px 14px 24px;vertical-align:middle;color:#fff;float:left}.mdl-snackbar__action{background:0 0;border:none;color:#ff4081;float:right;padding:14px 24px 14px 12px;font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:14px;font-weight:500;text-transform:uppercase;line-height:1;letter-spacing:0;overflow:hidden;outline:none;opacity:0;pointer-events:none;cursor:pointer;text-decoration:none;text-align:center;-webkit-align-self:center;-ms-flex-item-align:center;align-self:center}.mdl-snackbar__action::-moz-focus-inner{border:0}.mdl-snackbar__action:not([aria-hidden]){opacity:1;pointer-events:auto}.mdl-spinner{display:inline-block;position:relative;width:28px;height:28px}.mdl-spinner:not(.is-upgraded).is-active:after{content:"Loading..."}.mdl-spinner.is-upgraded.is-active{-webkit-animation:mdl-spinner__container-rotate 1568.23529412ms linear infinite;animation:mdl-spinner__container-rotate 1568.23529412ms linear infinite}@-webkit-keyframes mdl-spinner__container-rotate{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes mdl-spinner__container-rotate{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.mdl-spinner__layer{position:absolute;width:100%;height:100%;opacity:0}.mdl-spinner__layer-1{border-color:#42a5f5}.mdl-spinner--single-color .mdl-spinner__layer-1{border-color:#3f51b5}.mdl-spinner.is-active .mdl-spinner__layer-1{-webkit-animation:mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(.4,0,.2,1)infinite both,mdl-spinner__layer-1-fade-in-out 5332ms cubic-bezier(.4,0,.2,1)infinite both;animation:mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(.4,0,.2,1)infinite both,mdl-spinner__layer-1-fade-in-out 5332ms cubic-bezier(.4,0,.2,1)infinite both}.mdl-spinner__layer-2{border-color:#f44336}.mdl-spinner--single-color .mdl-spinner__layer-2{border-color:#3f51b5}.mdl-spinner.is-active .mdl-spinner__layer-2{-webkit-animation:mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(.4,0,.2,1)infinite both,mdl-spinner__layer-2-fade-in-out 5332ms cubic-bezier(.4,0,.2,1)infinite both;animation:mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(.4,0,.2,1)infinite both,mdl-spinner__layer-2-fade-in-out 5332ms cubic-bezier(.4,0,.2,1)infinite both}.mdl-spinner__layer-3{border-color:#fdd835}.mdl-spinner--single-color .mdl-spinner__layer-3{border-color:#3f51b5}.mdl-spinner.is-active .mdl-spinner__layer-3{-webkit-animation:mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(.4,0,.2,1)infinite both,mdl-spinner__layer-3-fade-in-out 5332ms cubic-bezier(.4,0,.2,1)infinite both;animation:mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(.4,0,.2,1)infinite both,mdl-spinner__layer-3-fade-in-out 5332ms cubic-bezier(.4,0,.2,1)infinite both}.mdl-spinner__layer-4{border-color:#4caf50}.mdl-spinner--single-color .mdl-spinner__layer-4{border-color:#3f51b5}.mdl-spinner.is-active .mdl-spinner__layer-4{-webkit-animation:mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(.4,0,.2,1)infinite both,mdl-spinner__layer-4-fade-in-out 5332ms cubic-bezier(.4,0,.2,1)infinite both;animation:mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(.4,0,.2,1)infinite both,mdl-spinner__layer-4-fade-in-out 5332ms cubic-bezier(.4,0,.2,1)infinite both}@-webkit-keyframes mdl-spinner__fill-unfill-rotate{12.5%{-webkit-transform:rotate(135deg);transform:rotate(135deg)}25%{-webkit-transform:rotate(270deg);transform:rotate(270deg)}37.5%{-webkit-transform:rotate(405deg);transform:rotate(405deg)}50%{-webkit-transform:rotate(540deg);transform:rotate(540deg)}62.5%{-webkit-transform:rotate(675deg);transform:rotate(675deg)}75%{-webkit-transform:rotate(810deg);transform:rotate(810deg)}87.5%{-webkit-transform:rotate(945deg);transform:rotate(945deg)}to{-webkit-transform:rotate(1080deg);transform:rotate(1080deg)}}@keyframes mdl-spinner__fill-unfill-rotate{12.5%{-webkit-transform:rotate(135deg);transform:rotate(135deg)}25%{-webkit-transform:rotate(270deg);transform:rotate(270deg)}37.5%{-webkit-transform:rotate(405deg);transform:rotate(405deg)}50%{-webkit-transform:rotate(540deg);transform:rotate(540deg)}62.5%{-webkit-transform:rotate(675deg);transform:rotate(675deg)}75%{-webkit-transform:rotate(810deg);transform:rotate(810deg)}87.5%{-webkit-transform:rotate(945deg);transform:rotate(945deg)}to{-webkit-transform:rotate(1080deg);transform:rotate(1080deg)}}@-webkit-keyframes mdl-spinner__layer-1-fade-in-out{from,25%{opacity:.99}26%,89%{opacity:0}90%,100%{opacity:.99}}@keyframes mdl-spinner__layer-1-fade-in-out{from,25%{opacity:.99}26%,89%{opacity:0}90%,100%{opacity:.99}}@-webkit-keyframes mdl-spinner__layer-2-fade-in-out{from,15%{opacity:0}25%,50%{opacity:.99}51%{opacity:0}}@keyframes mdl-spinner__layer-2-fade-in-out{from,15%{opacity:0}25%,50%{opacity:.99}51%{opacity:0}}@-webkit-keyframes mdl-spinner__layer-3-fade-in-out{from,40%{opacity:0}50%,75%{opacity:.99}76%{opacity:0}}@keyframes mdl-spinner__layer-3-fade-in-out{from,40%{opacity:0}50%,75%{opacity:.99}76%{opacity:0}}@-webkit-keyframes mdl-spinner__layer-4-fade-in-out{from,65%{opacity:0}75%,90%{opacity:.99}100%{opacity:0}}@keyframes mdl-spinner__layer-4-fade-in-out{from,65%{opacity:0}75%,90%{opacity:.99}100%{opacity:0}}.mdl-spinner__gap-patch{position:absolute;box-sizing:border-box;top:0;left:45%;width:10%;height:100%;overflow:hidden;border-color:inherit}.mdl-spinner__gap-patch .mdl-spinner__circle{width:1000%;left:-450%}.mdl-spinner__circle-clipper{display:inline-block;position:relative;width:50%;height:100%;overflow:hidden;border-color:inherit}.mdl-spinner__circle-clipper .mdl-spinner__circle{width:200%}.mdl-spinner__circle{box-sizing:border-box;height:100%;border-width:3px;border-style:solid;border-color:inherit;border-bottom-color:transparent!important;border-radius:50%;-webkit-animation:none;animation:none;position:absolute;top:0;right:0;bottom:0;left:0}.mdl-spinner__left .mdl-spinner__circle{border-right-color:transparent!important;-webkit-transform:rotate(129deg);transform:rotate(129deg)}.mdl-spinner.is-active .mdl-spinner__left .mdl-spinner__circle{-webkit-animation:mdl-spinner__left-spin 1333ms cubic-bezier(.4,0,.2,1)infinite both;animation:mdl-spinner__left-spin 1333ms cubic-bezier(.4,0,.2,1)infinite both}.mdl-spinner__right .mdl-spinner__circle{left:-100%;border-left-color:transparent!important;-webkit-transform:rotate(-129deg);transform:rotate(-129deg)}.mdl-spinner.is-active .mdl-spinner__right .mdl-spinner__circle{-webkit-animation:mdl-spinner__right-spin 1333ms cubic-bezier(.4,0,.2,1)infinite both;animation:mdl-spinner__right-spin 1333ms cubic-bezier(.4,0,.2,1)infinite both}@-webkit-keyframes mdl-spinner__left-spin{from{-webkit-transform:rotate(130deg);transform:rotate(130deg)}50%{-webkit-transform:rotate(-5deg);transform:rotate(-5deg)}to{-webkit-transform:rotate(130deg);transform:rotate(130deg)}}@keyframes mdl-spinner__left-spin{from{-webkit-transform:rotate(130deg);transform:rotate(130deg)}50%{-webkit-transform:rotate(-5deg);transform:rotate(-5deg)}to{-webkit-transform:rotate(130deg);transform:rotate(130deg)}}@-webkit-keyframes mdl-spinner__right-spin{from{-webkit-transform:rotate(-130deg);transform:rotate(-130deg)}50%{-webkit-transform:rotate(5deg);transform:rotate(5deg)}to{-webkit-transform:rotate(-130deg);transform:rotate(-130deg)}}@keyframes mdl-spinner__right-spin{from{-webkit-transform:rotate(-130deg);transform:rotate(-130deg)}50%{-webkit-transform:rotate(5deg);transform:rotate(5deg)}to{-webkit-transform:rotate(-130deg);transform:rotate(-130deg)}}.mdl-switch{position:relative;z-index:1;vertical-align:middle;display:inline-block;box-sizing:border-box;width:100%;height:24px;margin:0;padding:0;overflow:visible;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.mdl-switch.is-upgraded{padding-left:28px}.mdl-switch__input{line-height:24px}.mdl-switch.is-upgraded .mdl-switch__input{position:absolute;width:0;height:0;margin:0;padding:0;opacity:0;-ms-appearance:none;-moz-appearance:none;-webkit-appearance:none;appearance:none;border:none}.mdl-switch__track{background:rgba(0,0,0,.26);position:absolute;left:0;top:5px;height:14px;width:36px;border-radius:14px;cursor:pointer}.mdl-switch.is-checked .mdl-switch__track{background:rgba(63,81,181,.5)}.mdl-switch__track fieldset[disabled] .mdl-switch,.mdl-switch.is-disabled .mdl-switch__track{background:rgba(0,0,0,.12);cursor:auto}.mdl-switch__thumb{background:#fafafa;position:absolute;left:0;top:2px;height:20px;width:20px;border-radius:50%;cursor:pointer;box-shadow:0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);transition-duration:.28s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-property:left}.mdl-switch.is-checked .mdl-switch__thumb{background:#3f51b5;left:16px;box-shadow:0 3px 4px 0 rgba(0,0,0,.14),0 3px 3px -2px rgba(0,0,0,.2),0 1px 8px 0 rgba(0,0,0,.12)}.mdl-switch__thumb fieldset[disabled] .mdl-switch,.mdl-switch.is-disabled .mdl-switch__thumb{background:#bdbdbd;cursor:auto}.mdl-switch__focus-helper{position:absolute;top:50%;left:50%;-webkit-transform:translate(-4px,-4px);transform:translate(-4px,-4px);display:inline-block;box-sizing:border-box;width:8px;height:8px;border-radius:50%;background-color:transparent}.mdl-switch.is-focused .mdl-switch__focus-helper{box-shadow:0 0 0 20px rgba(0,0,0,.1);background-color:rgba(0,0,0,.1)}.mdl-switch.is-focused.is-checked .mdl-switch__focus-helper{box-shadow:0 0 0 20px rgba(63,81,181,.26);background-color:rgba(63,81,181,.26)}.mdl-switch__label{position:relative;cursor:pointer;font-size:16px;line-height:24px;margin:0;left:24px}.mdl-switch__label fieldset[disabled] .mdl-switch,.mdl-switch.is-disabled .mdl-switch__label{color:#bdbdbd;cursor:auto}.mdl-switch__ripple-container{position:absolute;z-index:2;top:-12px;left:-14px;box-sizing:border-box;width:48px;height:48px;border-radius:50%;cursor:pointer;overflow:hidden;-webkit-mask-image:-webkit-radial-gradient(circle,#fff,#000);transition-duration:.4s;transition-timing-function:step-end;transition-property:left}.mdl-switch__ripple-container .mdl-ripple{background:#3f51b5}.mdl-switch__ripple-container fieldset[disabled] .mdl-switch,.mdl-switch.is-disabled .mdl-switch__ripple-container{cursor:auto}fieldset[disabled] .mdl-switch .mdl-switch__ripple-container .mdl-ripple,.mdl-switch.is-disabled .mdl-switch__ripple-container .mdl-ripple{background:0 0}.mdl-switch.is-checked .mdl-switch__ripple-container{left:2px}.mdl-tabs{display:block;width:100%}.mdl-tabs__tab-bar{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center;-webkit-align-content:space-between;-ms-flex-line-pack:justify;align-content:space-between;-webkit-align-items:flex-start;-ms-flex-align:start;align-items:flex-start;height:48px;padding:0;margin:0;border-bottom:1px solid #e0e0e0}.mdl-tabs__tab{margin:0;border:none;padding:0 24px;float:left;position:relative;display:block;text-decoration:none;height:48px;line-height:48px;text-align:center;font-weight:500;font-size:14px;text-transform:uppercase;color:rgba(0,0,0,.54);overflow:hidden}.mdl-tabs.is-upgraded .mdl-tabs__tab.is-active{color:rgba(0,0,0,.87)}.mdl-tabs.is-upgraded .mdl-tabs__tab.is-active:after{height:2px;width:100%;display:block;content:" ";bottom:0;left:0;position:absolute;background:#3f51b5;-webkit-animation:border-expand .2s cubic-bezier(.4,0,.4,1).01s alternate forwards;animation:border-expand .2s cubic-bezier(.4,0,.4,1).01s alternate forwards;transition:all 1s cubic-bezier(.4,0,1,1)}.mdl-tabs__tab .mdl-tabs__ripple-container{display:block;position:absolute;height:100%;width:100%;left:0;top:0;z-index:1;overflow:hidden}.mdl-tabs__tab .mdl-tabs__ripple-container .mdl-ripple{background:#3f51b5}.mdl-tabs__panel{display:block}.mdl-tabs.is-upgraded .mdl-tabs__panel{display:none}.mdl-tabs.is-upgraded .mdl-tabs__panel.is-active{display:block}@-webkit-keyframes border-expand{0%{opacity:0;width:0}100%{opacity:1;width:100%}}@keyframes border-expand{0%{opacity:0;width:0}100%{opacity:1;width:100%}}.mdl-textfield{position:relative;font-size:16px;display:inline-block;box-sizing:border-box;width:300px;max-width:100%;margin:0;padding:20px 0}.mdl-textfield .mdl-button{position:absolute;bottom:20px}.mdl-textfield--align-right{text-align:right}.mdl-textfield--full-width{width:100%}.mdl-textfield--expandable{min-width:32px;width:auto;min-height:32px}.mdl-textfield__input{border:none;border-bottom:1px solid rgba(0,0,0,.12);display:block;font-size:16px;font-family:"Helvetica","Arial",sans-serif;margin:0;padding:4px 0;width:100%;background:0 0;text-align:left;color:inherit}.mdl-textfield__input[type="number"]{-moz-appearance:textfield}.mdl-textfield__input[type="number"]::-webkit-inner-spin-button,.mdl-textfield__input[type="number"]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.mdl-textfield.is-focused .mdl-textfield__input{outline:none}.mdl-textfield.is-invalid .mdl-textfield__input{border-color:#d50000;box-shadow:none}fieldset[disabled] .mdl-textfield .mdl-textfield__input,.mdl-textfield.is-disabled .mdl-textfield__input{background-color:transparent;border-bottom:1px dotted rgba(0,0,0,.12);color:rgba(0,0,0,.26)}.mdl-textfield textarea.mdl-textfield__input{display:block}.mdl-textfield__label{bottom:0;color:rgba(0,0,0,.26);font-size:16px;left:0;right:0;pointer-events:none;position:absolute;display:block;top:24px;width:100%;overflow:hidden;white-space:nowrap;text-align:left}.mdl-textfield.is-dirty .mdl-textfield__label,.mdl-textfield.has-placeholder .mdl-textfield__label{visibility:hidden}.mdl-textfield--floating-label .mdl-textfield__label{transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.mdl-textfield--floating-label.has-placeholder .mdl-textfield__label{transition:none}fieldset[disabled] .mdl-textfield .mdl-textfield__label,.mdl-textfield.is-disabled.is-disabled .mdl-textfield__label{color:rgba(0,0,0,.26)}.mdl-textfield--floating-label.is-focused .mdl-textfield__label,.mdl-textfield--floating-label.is-dirty .mdl-textfield__label,.mdl-textfield--floating-label.has-placeholder .mdl-textfield__label{color:#3f51b5;font-size:12px;top:4px;visibility:visible}.mdl-textfield--floating-label.is-focused .mdl-textfield__expandable-holder .mdl-textfield__label,.mdl-textfield--floating-label.is-dirty .mdl-textfield__expandable-holder .mdl-textfield__label,.mdl-textfield--floating-label.has-placeholder .mdl-textfield__expandable-holder .mdl-textfield__label{top:-16px}.mdl-textfield--floating-label.is-invalid .mdl-textfield__label{color:#d50000;font-size:12px}.mdl-textfield__label:after{background-color:#3f51b5;bottom:20px;content:'';height:2px;left:45%;position:absolute;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);visibility:hidden;width:10px}.mdl-textfield.is-focused .mdl-textfield__label:after{left:0;visibility:visible;width:100%}.mdl-textfield.is-invalid .mdl-textfield__label:after{background-color:#d50000}.mdl-textfield__error{color:#d50000;position:absolute;font-size:12px;margin-top:3px;visibility:hidden;display:block}.mdl-textfield.is-invalid .mdl-textfield__error{visibility:visible}.mdl-textfield__expandable-holder{display:inline-block;position:relative;margin-left:32px;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:inline-block;max-width:.1px}.mdl-textfield.is-focused .mdl-textfield__expandable-holder,.mdl-textfield.is-dirty .mdl-textfield__expandable-holder{max-width:600px}.mdl-textfield__expandable-holder .mdl-textfield__label:after{bottom:0}.mdl-tooltip{-webkit-transform:scale(0);transform:scale(0);-webkit-transform-origin:top center;transform-origin:top center;will-change:transform;z-index:999;background:rgba(97,97,97,.9);border-radius:2px;color:#fff;display:inline-block;font-size:10px;font-weight:500;line-height:14px;max-width:170px;position:fixed;top:-500px;left:-500px;padding:8px;text-align:center}.mdl-tooltip.is-active{-webkit-animation:pulse 200ms cubic-bezier(0,0,.2,1)forwards;animation:pulse 200ms cubic-bezier(0,0,.2,1)forwards}.mdl-tooltip--large{line-height:14px;font-size:14px;padding:16px}@-webkit-keyframes pulse{0%{-webkit-transform:scale(0);transform:scale(0);opacity:0}50%{-webkit-transform:scale(.99);transform:scale(.99)}100%{-webkit-transform:scale(1);transform:scale(1);opacity:1;visibility:visible}}@keyframes pulse{0%{-webkit-transform:scale(0);transform:scale(0);opacity:0}50%{-webkit-transform:scale(.99);transform:scale(.99)}100%{-webkit-transform:scale(1);transform:scale(1);opacity:1;visibility:visible}}.mdl-shadow--2dp{box-shadow:0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12)}.mdl-shadow--3dp{box-shadow:0 3px 4px 0 rgba(0,0,0,.14),0 3px 3px -2px rgba(0,0,0,.2),0 1px 8px 0 rgba(0,0,0,.12)}.mdl-shadow--4dp{box-shadow:0 4px 5px 0 rgba(0,0,0,.14),0 1px 10px 0 rgba(0,0,0,.12),0 2px 4px -1px rgba(0,0,0,.2)}.mdl-shadow--6dp{box-shadow:0 6px 10px 0 rgba(0,0,0,.14),0 1px 18px 0 rgba(0,0,0,.12),0 3px 5px -1px rgba(0,0,0,.2)}.mdl-shadow--8dp{box-shadow:0 8px 10px 1px rgba(0,0,0,.14),0 3px 14px 2px rgba(0,0,0,.12),0 5px 5px -3px rgba(0,0,0,.2)}.mdl-shadow--16dp{box-shadow:0 16px 24px 2px rgba(0,0,0,.14),0 6px 30px 5px rgba(0,0,0,.12),0 8px 10px -5px rgba(0,0,0,.2)}.mdl-shadow--24dp{box-shadow:0 9px 46px 8px rgba(0,0,0,.14),0 11px 15px -7px rgba(0,0,0,.12),0 24px 38px 3px rgba(0,0,0,.2)}.mdl-grid{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-flow:row wrap;-ms-flex-flow:row wrap;flex-flow:row wrap;margin:0 auto;-webkit-align-items:stretch;-ms-flex-align:stretch;align-items:stretch}.mdl-grid.mdl-grid--no-spacing{padding:0}.mdl-cell{box-sizing:border-box}.mdl-cell--top{-webkit-align-self:flex-start;-ms-flex-item-align:start;align-self:flex-start}.mdl-cell--middle{-webkit-align-self:center;-ms-flex-item-align:center;align-self:center}.mdl-cell--bottom{-webkit-align-self:flex-end;-ms-flex-item-align:end;align-self:flex-end}.mdl-cell--stretch{-webkit-align-self:stretch;-ms-flex-item-align:stretch;align-self:stretch}.mdl-grid.mdl-grid--no-spacing>.mdl-cell{margin:0}.mdl-cell--order-1{-webkit-order:1;-ms-flex-order:1;order:1}.mdl-cell--order-2{-webkit-order:2;-ms-flex-order:2;order:2}.mdl-cell--order-3{-webkit-order:3;-ms-flex-order:3;order:3}.mdl-cell--order-4{-webkit-order:4;-ms-flex-order:4;order:4}.mdl-cell--order-5{-webkit-order:5;-ms-flex-order:5;order:5}.mdl-cell--order-6{-webkit-order:6;-ms-flex-order:6;order:6}.mdl-cell--order-7{-webkit-order:7;-ms-flex-order:7;order:7}.mdl-cell--order-8{-webkit-order:8;-ms-flex-order:8;order:8}.mdl-cell--order-9{-webkit-order:9;-ms-flex-order:9;order:9}.mdl-cell--order-10{-webkit-order:10;-ms-flex-order:10;order:10}.mdl-cell--order-11{-webkit-order:11;-ms-flex-order:11;order:11}.mdl-cell--order-12{-webkit-order:12;-ms-flex-order:12;order:12}@media (max-width:479px){.mdl-grid{padding:8px}.mdl-cell{margin:8px;width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell{width:100%}.mdl-cell--hide-phone{display:none!important}.mdl-cell--order-1-phone.mdl-cell--order-1-phone{-webkit-order:1;-ms-flex-order:1;order:1}.mdl-cell--order-2-phone.mdl-cell--order-2-phone{-webkit-order:2;-ms-flex-order:2;order:2}.mdl-cell--order-3-phone.mdl-cell--order-3-phone{-webkit-order:3;-ms-flex-order:3;order:3}.mdl-cell--order-4-phone.mdl-cell--order-4-phone{-webkit-order:4;-ms-flex-order:4;order:4}.mdl-cell--order-5-phone.mdl-cell--order-5-phone{-webkit-order:5;-ms-flex-order:5;order:5}.mdl-cell--order-6-phone.mdl-cell--order-6-phone{-webkit-order:6;-ms-flex-order:6;order:6}.mdl-cell--order-7-phone.mdl-cell--order-7-phone{-webkit-order:7;-ms-flex-order:7;order:7}.mdl-cell--order-8-phone.mdl-cell--order-8-phone{-webkit-order:8;-ms-flex-order:8;order:8}.mdl-cell--order-9-phone.mdl-cell--order-9-phone{-webkit-order:9;-ms-flex-order:9;order:9}.mdl-cell--order-10-phone.mdl-cell--order-10-phone{-webkit-order:10;-ms-flex-order:10;order:10}.mdl-cell--order-11-phone.mdl-cell--order-11-phone{-webkit-order:11;-ms-flex-order:11;order:11}.mdl-cell--order-12-phone.mdl-cell--order-12-phone{-webkit-order:12;-ms-flex-order:12;order:12}.mdl-cell--1-col,.mdl-cell--1-col-phone.mdl-cell--1-col-phone{width:calc(25% - 16px)}.mdl-grid--no-spacing>.mdl-cell--1-col,.mdl-grid--no-spacing>.mdl-cell--1-col-phone.mdl-cell--1-col-phone{width:25%}.mdl-cell--2-col,.mdl-cell--2-col-phone.mdl-cell--2-col-phone{width:calc(50% - 16px)}.mdl-grid--no-spacing>.mdl-cell--2-col,.mdl-grid--no-spacing>.mdl-cell--2-col-phone.mdl-cell--2-col-phone{width:50%}.mdl-cell--3-col,.mdl-cell--3-col-phone.mdl-cell--3-col-phone{width:calc(75% - 16px)}.mdl-grid--no-spacing>.mdl-cell--3-col,.mdl-grid--no-spacing>.mdl-cell--3-col-phone.mdl-cell--3-col-phone{width:75%}.mdl-cell--4-col,.mdl-cell--4-col-phone.mdl-cell--4-col-phone{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--4-col,.mdl-grid--no-spacing>.mdl-cell--4-col-phone.mdl-cell--4-col-phone{width:100%}.mdl-cell--5-col,.mdl-cell--5-col-phone.mdl-cell--5-col-phone{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--5-col,.mdl-grid--no-spacing>.mdl-cell--5-col-phone.mdl-cell--5-col-phone{width:100%}.mdl-cell--6-col,.mdl-cell--6-col-phone.mdl-cell--6-col-phone{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--6-col,.mdl-grid--no-spacing>.mdl-cell--6-col-phone.mdl-cell--6-col-phone{width:100%}.mdl-cell--7-col,.mdl-cell--7-col-phone.mdl-cell--7-col-phone{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--7-col,.mdl-grid--no-spacing>.mdl-cell--7-col-phone.mdl-cell--7-col-phone{width:100%}.mdl-cell--8-col,.mdl-cell--8-col-phone.mdl-cell--8-col-phone{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--8-col,.mdl-grid--no-spacing>.mdl-cell--8-col-phone.mdl-cell--8-col-phone{width:100%}.mdl-cell--9-col,.mdl-cell--9-col-phone.mdl-cell--9-col-phone{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--9-col,.mdl-grid--no-spacing>.mdl-cell--9-col-phone.mdl-cell--9-col-phone{width:100%}.mdl-cell--10-col,.mdl-cell--10-col-phone.mdl-cell--10-col-phone{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--10-col,.mdl-grid--no-spacing>.mdl-cell--10-col-phone.mdl-cell--10-col-phone{width:100%}.mdl-cell--11-col,.mdl-cell--11-col-phone.mdl-cell--11-col-phone{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--11-col,.mdl-grid--no-spacing>.mdl-cell--11-col-phone.mdl-cell--11-col-phone{width:100%}.mdl-cell--12-col,.mdl-cell--12-col-phone.mdl-cell--12-col-phone{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--12-col,.mdl-grid--no-spacing>.mdl-cell--12-col-phone.mdl-cell--12-col-phone{width:100%}.mdl-cell--1-offset,.mdl-cell--1-offset-phone.mdl-cell--1-offset-phone{margin-left:calc(25% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--1-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--1-offset-phone.mdl-cell--1-offset-phone{margin-left:25%}.mdl-cell--2-offset,.mdl-cell--2-offset-phone.mdl-cell--2-offset-phone{margin-left:calc(50% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--2-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--2-offset-phone.mdl-cell--2-offset-phone{margin-left:50%}.mdl-cell--3-offset,.mdl-cell--3-offset-phone.mdl-cell--3-offset-phone{margin-left:calc(75% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--3-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--3-offset-phone.mdl-cell--3-offset-phone{margin-left:75%}}@media (min-width:480px) and (max-width:839px){.mdl-grid{padding:8px}.mdl-cell{margin:8px;width:calc(50% - 16px)}.mdl-grid--no-spacing>.mdl-cell{width:50%}.mdl-cell--hide-tablet{display:none!important}.mdl-cell--order-1-tablet.mdl-cell--order-1-tablet{-webkit-order:1;-ms-flex-order:1;order:1}.mdl-cell--order-2-tablet.mdl-cell--order-2-tablet{-webkit-order:2;-ms-flex-order:2;order:2}.mdl-cell--order-3-tablet.mdl-cell--order-3-tablet{-webkit-order:3;-ms-flex-order:3;order:3}.mdl-cell--order-4-tablet.mdl-cell--order-4-tablet{-webkit-order:4;-ms-flex-order:4;order:4}.mdl-cell--order-5-tablet.mdl-cell--order-5-tablet{-webkit-order:5;-ms-flex-order:5;order:5}.mdl-cell--order-6-tablet.mdl-cell--order-6-tablet{-webkit-order:6;-ms-flex-order:6;order:6}.mdl-cell--order-7-tablet.mdl-cell--order-7-tablet{-webkit-order:7;-ms-flex-order:7;order:7}.mdl-cell--order-8-tablet.mdl-cell--order-8-tablet{-webkit-order:8;-ms-flex-order:8;order:8}.mdl-cell--order-9-tablet.mdl-cell--order-9-tablet{-webkit-order:9;-ms-flex-order:9;order:9}.mdl-cell--order-10-tablet.mdl-cell--order-10-tablet{-webkit-order:10;-ms-flex-order:10;order:10}.mdl-cell--order-11-tablet.mdl-cell--order-11-tablet{-webkit-order:11;-ms-flex-order:11;order:11}.mdl-cell--order-12-tablet.mdl-cell--order-12-tablet{-webkit-order:12;-ms-flex-order:12;order:12}.mdl-cell--1-col,.mdl-cell--1-col-tablet.mdl-cell--1-col-tablet{width:calc(12.5% - 16px)}.mdl-grid--no-spacing>.mdl-cell--1-col,.mdl-grid--no-spacing>.mdl-cell--1-col-tablet.mdl-cell--1-col-tablet{width:12.5%}.mdl-cell--2-col,.mdl-cell--2-col-tablet.mdl-cell--2-col-tablet{width:calc(25% - 16px)}.mdl-grid--no-spacing>.mdl-cell--2-col,.mdl-grid--no-spacing>.mdl-cell--2-col-tablet.mdl-cell--2-col-tablet{width:25%}.mdl-cell--3-col,.mdl-cell--3-col-tablet.mdl-cell--3-col-tablet{width:calc(37.5% - 16px)}.mdl-grid--no-spacing>.mdl-cell--3-col,.mdl-grid--no-spacing>.mdl-cell--3-col-tablet.mdl-cell--3-col-tablet{width:37.5%}.mdl-cell--4-col,.mdl-cell--4-col-tablet.mdl-cell--4-col-tablet{width:calc(50% - 16px)}.mdl-grid--no-spacing>.mdl-cell--4-col,.mdl-grid--no-spacing>.mdl-cell--4-col-tablet.mdl-cell--4-col-tablet{width:50%}.mdl-cell--5-col,.mdl-cell--5-col-tablet.mdl-cell--5-col-tablet{width:calc(62.5% - 16px)}.mdl-grid--no-spacing>.mdl-cell--5-col,.mdl-grid--no-spacing>.mdl-cell--5-col-tablet.mdl-cell--5-col-tablet{width:62.5%}.mdl-cell--6-col,.mdl-cell--6-col-tablet.mdl-cell--6-col-tablet{width:calc(75% - 16px)}.mdl-grid--no-spacing>.mdl-cell--6-col,.mdl-grid--no-spacing>.mdl-cell--6-col-tablet.mdl-cell--6-col-tablet{width:75%}.mdl-cell--7-col,.mdl-cell--7-col-tablet.mdl-cell--7-col-tablet{width:calc(87.5% - 16px)}.mdl-grid--no-spacing>.mdl-cell--7-col,.mdl-grid--no-spacing>.mdl-cell--7-col-tablet.mdl-cell--7-col-tablet{width:87.5%}.mdl-cell--8-col,.mdl-cell--8-col-tablet.mdl-cell--8-col-tablet{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--8-col,.mdl-grid--no-spacing>.mdl-cell--8-col-tablet.mdl-cell--8-col-tablet{width:100%}.mdl-cell--9-col,.mdl-cell--9-col-tablet.mdl-cell--9-col-tablet{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--9-col,.mdl-grid--no-spacing>.mdl-cell--9-col-tablet.mdl-cell--9-col-tablet{width:100%}.mdl-cell--10-col,.mdl-cell--10-col-tablet.mdl-cell--10-col-tablet{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--10-col,.mdl-grid--no-spacing>.mdl-cell--10-col-tablet.mdl-cell--10-col-tablet{width:100%}.mdl-cell--11-col,.mdl-cell--11-col-tablet.mdl-cell--11-col-tablet{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--11-col,.mdl-grid--no-spacing>.mdl-cell--11-col-tablet.mdl-cell--11-col-tablet{width:100%}.mdl-cell--12-col,.mdl-cell--12-col-tablet.mdl-cell--12-col-tablet{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--12-col,.mdl-grid--no-spacing>.mdl-cell--12-col-tablet.mdl-cell--12-col-tablet{width:100%}.mdl-cell--1-offset,.mdl-cell--1-offset-tablet.mdl-cell--1-offset-tablet{margin-left:calc(12.5% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--1-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--1-offset-tablet.mdl-cell--1-offset-tablet{margin-left:12.5%}.mdl-cell--2-offset,.mdl-cell--2-offset-tablet.mdl-cell--2-offset-tablet{margin-left:calc(25% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--2-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--2-offset-tablet.mdl-cell--2-offset-tablet{margin-left:25%}.mdl-cell--3-offset,.mdl-cell--3-offset-tablet.mdl-cell--3-offset-tablet{margin-left:calc(37.5% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--3-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--3-offset-tablet.mdl-cell--3-offset-tablet{margin-left:37.5%}.mdl-cell--4-offset,.mdl-cell--4-offset-tablet.mdl-cell--4-offset-tablet{margin-left:calc(50% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--4-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--4-offset-tablet.mdl-cell--4-offset-tablet{margin-left:50%}.mdl-cell--5-offset,.mdl-cell--5-offset-tablet.mdl-cell--5-offset-tablet{margin-left:calc(62.5% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--5-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--5-offset-tablet.mdl-cell--5-offset-tablet{margin-left:62.5%}.mdl-cell--6-offset,.mdl-cell--6-offset-tablet.mdl-cell--6-offset-tablet{margin-left:calc(75% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--6-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--6-offset-tablet.mdl-cell--6-offset-tablet{margin-left:75%}.mdl-cell--7-offset,.mdl-cell--7-offset-tablet.mdl-cell--7-offset-tablet{margin-left:calc(87.5% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--7-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--7-offset-tablet.mdl-cell--7-offset-tablet{margin-left:87.5%}}@media (min-width:840px){.mdl-grid{padding:8px}.mdl-cell{margin:8px;width:calc(33.3333333333% - 16px)}.mdl-grid--no-spacing>.mdl-cell{width:33.3333333333%}.mdl-cell--hide-desktop{display:none!important}.mdl-cell--order-1-desktop.mdl-cell--order-1-desktop{-webkit-order:1;-ms-flex-order:1;order:1}.mdl-cell--order-2-desktop.mdl-cell--order-2-desktop{-webkit-order:2;-ms-flex-order:2;order:2}.mdl-cell--order-3-desktop.mdl-cell--order-3-desktop{-webkit-order:3;-ms-flex-order:3;order:3}.mdl-cell--order-4-desktop.mdl-cell--order-4-desktop{-webkit-order:4;-ms-flex-order:4;order:4}.mdl-cell--order-5-desktop.mdl-cell--order-5-desktop{-webkit-order:5;-ms-flex-order:5;order:5}.mdl-cell--order-6-desktop.mdl-cell--order-6-desktop{-webkit-order:6;-ms-flex-order:6;order:6}.mdl-cell--order-7-desktop.mdl-cell--order-7-desktop{-webkit-order:7;-ms-flex-order:7;order:7}.mdl-cell--order-8-desktop.mdl-cell--order-8-desktop{-webkit-order:8;-ms-flex-order:8;order:8}.mdl-cell--order-9-desktop.mdl-cell--order-9-desktop{-webkit-order:9;-ms-flex-order:9;order:9}.mdl-cell--order-10-desktop.mdl-cell--order-10-desktop{-webkit-order:10;-ms-flex-order:10;order:10}.mdl-cell--order-11-desktop.mdl-cell--order-11-desktop{-webkit-order:11;-ms-flex-order:11;order:11}.mdl-cell--order-12-desktop.mdl-cell--order-12-desktop{-webkit-order:12;-ms-flex-order:12;order:12}.mdl-cell--1-col,.mdl-cell--1-col-desktop.mdl-cell--1-col-desktop{width:calc(8.3333333333% - 16px)}.mdl-grid--no-spacing>.mdl-cell--1-col,.mdl-grid--no-spacing>.mdl-cell--1-col-desktop.mdl-cell--1-col-desktop{width:8.3333333333%}.mdl-cell--2-col,.mdl-cell--2-col-desktop.mdl-cell--2-col-desktop{width:calc(16.6666666667% - 16px)}.mdl-grid--no-spacing>.mdl-cell--2-col,.mdl-grid--no-spacing>.mdl-cell--2-col-desktop.mdl-cell--2-col-desktop{width:16.6666666667%}.mdl-cell--3-col,.mdl-cell--3-col-desktop.mdl-cell--3-col-desktop{width:calc(25% - 16px)}.mdl-grid--no-spacing>.mdl-cell--3-col,.mdl-grid--no-spacing>.mdl-cell--3-col-desktop.mdl-cell--3-col-desktop{width:25%}.mdl-cell--4-col,.mdl-cell--4-col-desktop.mdl-cell--4-col-desktop{width:calc(33.3333333333% - 16px)}.mdl-grid--no-spacing>.mdl-cell--4-col,.mdl-grid--no-spacing>.mdl-cell--4-col-desktop.mdl-cell--4-col-desktop{width:33.3333333333%}.mdl-cell--5-col,.mdl-cell--5-col-desktop.mdl-cell--5-col-desktop{width:calc(41.6666666667% - 16px)}.mdl-grid--no-spacing>.mdl-cell--5-col,.mdl-grid--no-spacing>.mdl-cell--5-col-desktop.mdl-cell--5-col-desktop{width:41.6666666667%}.mdl-cell--6-col,.mdl-cell--6-col-desktop.mdl-cell--6-col-desktop{width:calc(50% - 16px)}.mdl-grid--no-spacing>.mdl-cell--6-col,.mdl-grid--no-spacing>.mdl-cell--6-col-desktop.mdl-cell--6-col-desktop{width:50%}.mdl-cell--7-col,.mdl-cell--7-col-desktop.mdl-cell--7-col-desktop{width:calc(58.3333333333% - 16px)}.mdl-grid--no-spacing>.mdl-cell--7-col,.mdl-grid--no-spacing>.mdl-cell--7-col-desktop.mdl-cell--7-col-desktop{width:58.3333333333%}.mdl-cell--8-col,.mdl-cell--8-col-desktop.mdl-cell--8-col-desktop{width:calc(66.6666666667% - 16px)}.mdl-grid--no-spacing>.mdl-cell--8-col,.mdl-grid--no-spacing>.mdl-cell--8-col-desktop.mdl-cell--8-col-desktop{width:66.6666666667%}.mdl-cell--9-col,.mdl-cell--9-col-desktop.mdl-cell--9-col-desktop{width:calc(75% - 16px)}.mdl-grid--no-spacing>.mdl-cell--9-col,.mdl-grid--no-spacing>.mdl-cell--9-col-desktop.mdl-cell--9-col-desktop{width:75%}.mdl-cell--10-col,.mdl-cell--10-col-desktop.mdl-cell--10-col-desktop{width:calc(83.3333333333% - 16px)}.mdl-grid--no-spacing>.mdl-cell--10-col,.mdl-grid--no-spacing>.mdl-cell--10-col-desktop.mdl-cell--10-col-desktop{width:83.3333333333%}.mdl-cell--11-col,.mdl-cell--11-col-desktop.mdl-cell--11-col-desktop{width:calc(91.6666666667% - 16px)}.mdl-grid--no-spacing>.mdl-cell--11-col,.mdl-grid--no-spacing>.mdl-cell--11-col-desktop.mdl-cell--11-col-desktop{width:91.6666666667%}.mdl-cell--12-col,.mdl-cell--12-col-desktop.mdl-cell--12-col-desktop{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--12-col,.mdl-grid--no-spacing>.mdl-cell--12-col-desktop.mdl-cell--12-col-desktop{width:100%}.mdl-cell--1-offset,.mdl-cell--1-offset-desktop.mdl-cell--1-offset-desktop{margin-left:calc(8.3333333333% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--1-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--1-offset-desktop.mdl-cell--1-offset-desktop{margin-left:8.3333333333%}.mdl-cell--2-offset,.mdl-cell--2-offset-desktop.mdl-cell--2-offset-desktop{margin-left:calc(16.6666666667% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--2-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--2-offset-desktop.mdl-cell--2-offset-desktop{margin-left:16.6666666667%}.mdl-cell--3-offset,.mdl-cell--3-offset-desktop.mdl-cell--3-offset-desktop{margin-left:calc(25% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--3-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--3-offset-desktop.mdl-cell--3-offset-desktop{margin-left:25%}.mdl-cell--4-offset,.mdl-cell--4-offset-desktop.mdl-cell--4-offset-desktop{margin-left:calc(33.3333333333% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--4-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--4-offset-desktop.mdl-cell--4-offset-desktop{margin-left:33.3333333333%}.mdl-cell--5-offset,.mdl-cell--5-offset-desktop.mdl-cell--5-offset-desktop{margin-left:calc(41.6666666667% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--5-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--5-offset-desktop.mdl-cell--5-offset-desktop{margin-left:41.6666666667%}.mdl-cell--6-offset,.mdl-cell--6-offset-desktop.mdl-cell--6-offset-desktop{margin-left:calc(50% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--6-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--6-offset-desktop.mdl-cell--6-offset-desktop{margin-left:50%}.mdl-cell--7-offset,.mdl-cell--7-offset-desktop.mdl-cell--7-offset-desktop{margin-left:calc(58.3333333333% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--7-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--7-offset-desktop.mdl-cell--7-offset-desktop{margin-left:58.3333333333%}.mdl-cell--8-offset,.mdl-cell--8-offset-desktop.mdl-cell--8-offset-desktop{margin-left:calc(66.6666666667% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--8-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--8-offset-desktop.mdl-cell--8-offset-desktop{margin-left:66.6666666667%}.mdl-cell--9-offset,.mdl-cell--9-offset-desktop.mdl-cell--9-offset-desktop{margin-left:calc(75% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--9-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--9-offset-desktop.mdl-cell--9-offset-desktop{margin-left:75%}.mdl-cell--10-offset,.mdl-cell--10-offset-desktop.mdl-cell--10-offset-desktop{margin-left:calc(83.3333333333% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--10-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--10-offset-desktop.mdl-cell--10-offset-desktop{margin-left:83.3333333333%}.mdl-cell--11-offset,.mdl-cell--11-offset-desktop.mdl-cell--11-offset-desktop{margin-left:calc(91.6666666667% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--11-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--11-offset-desktop.mdl-cell--11-offset-desktop{margin-left:91.6666666667%}} -/*# sourceMappingURL=material.min.css.map */ diff --git a/etc/cli.angular.io/theme.css b/etc/cli.angular.io/theme.css deleted file mode 100644 index b6a336e98b0c..000000000000 --- a/etc/cli.angular.io/theme.css +++ /dev/null @@ -1 +0,0 @@ -.console{width:360px;max-width:92vw;margin-left:15px;margin-right:40px;text-align:left;border-radius:5px;margin-bottom:10px}@media (max-width:830px){.console{margin-right:auto;margin-left:auto}}.console__head{overflow:hidden;background-color:#d5d5d5;padding:8px 15px;border-top-left-radius:5px;border-top-right-radius:5px}.console__dot{float:left;width:12px;height:12px;border-radius:50%;margin-right:7px;box-shadow:0 1px 1px 0 rgba(0,0,0,.2)}.console__dot--red{background-color:#ff6057}.console__dot--yellow{background-color:#ffc22e}.console__dot--green{background-color:#28ca40}.console__body{background-color:#1e1e1e;padding:30px 17px 20px;border-bottom-left-radius:5px;border-bottom-right-radius:5px}.console__prompt{display:block;margin-bottom:15px;font-family:"Source Code Pro",monospace;font-size:15px}.console__prompt::before{content:">";padding-right:15px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.mdl-base{height:100vh} diff --git a/etc/rules/README.md b/etc/rules/README.md deleted file mode 100644 index 616923160a2b..000000000000 --- a/etc/rules/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# TsLint Rules - -This folder contains custom TsLint rules specific to this repository. diff --git a/etc/rules/Rule.ts b/etc/rules/Rule.ts deleted file mode 100644 index 43a7df63b777..000000000000 --- a/etc/rules/Rule.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @license - * 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 - */ -import * as Lint from 'tslint'; -import * as ts from 'typescript'; - - -// An empty rule so that tslint does not error on rules '//' (which are comments). -export class Rule extends Lint.Rules.AbstractRule { - public static metadata: Lint.IRuleMetadata = { - ruleName: '//', - type: 'typescript', - description: ``, - rationale: '', - options: null, - optionsDescription: `Not configurable.`, - typescriptOnly: false, - }; - - public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { - return []; - } -} diff --git a/etc/rules/defocusRule.ts b/etc/rules/defocusRule.ts deleted file mode 100644 index 0634596fa24f..000000000000 --- a/etc/rules/defocusRule.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * @license - * 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 - */ - -/* - * Taken from https://github.com/Sergiioo/tslint-defocus - * Copyright (c) 2016 Sergio Annecchiarico - * MIT - https://github.com/Sergiioo/tslint-defocus/blob/master/LICENSE - */ - -import * as Lint from 'tslint'; -import * as ts from 'typescript'; - -export class Rule extends Lint.Rules.AbstractRule { - - public static metadata: Lint.IRuleMetadata = { - ruleName: 'defocus', - description: "Bans the use of `fdescribe` and 'fit' Jasmine functions.", - rationale: 'It is all too easy to mistakenly commit a focussed Jasmine test suite or spec.', - options: null, - optionsDescription: 'Not configurable.', - type: 'functionality', - typescriptOnly: false, - }; - - public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { - return this.applyWithFunction(sourceFile, walk); - } -} - -function walk(ctx: Lint.WalkContext) { - return ts.forEachChild(ctx.sourceFile, function cb(node: ts.Node): void { - if (node.kind === ts.SyntaxKind.CallExpression) { - const expression = (node as ts.CallExpression).expression; - const functionName = expression.getText(); - bannedFunctions.forEach((banned) => { - if (banned === functionName) { - ctx.addFailureAtNode(expression, failureMessage(functionName)); - } - }); - } - - return ts.forEachChild(node, cb); - }); -} - -const bannedFunctions: ReadonlyArray = ['fdescribe', 'fit']; - -const failureMessage = (functionName: string) => { - return `Calls to '${functionName}' are not allowed.`; -}; diff --git a/etc/rules/importGroupsRule.ts b/etc/rules/importGroupsRule.ts deleted file mode 100644 index c53fcba8c7d2..000000000000 --- a/etc/rules/importGroupsRule.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * @license - * 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 - */ -import * as Lint from 'tslint'; -import * as ts from 'typescript'; - - -export class Rule extends Lint.Rules.AbstractRule { - public static metadata: Lint.IRuleMetadata = { - ruleName: 'import-groups', - type: 'style', - description: `Ensure imports are grouped.`, - rationale: `Imports can be grouped or not depending on a project. A group is a sequence of - import statements separated by blank lines.`, - options: null, - optionsDescription: `Not configurable.`, - typescriptOnly: false, - }; - - public static FAILURE_STRING = 'You need to keep imports grouped.'; - - public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { - return this.applyWithWalker(new Walker(sourceFile, this.getOptions())); - } -} - - -class Walker extends Lint.RuleWalker { - walk(sourceFile: ts.SourceFile) { - super.walk(sourceFile); - - const statements = sourceFile.statements; - const imports = statements.filter(s => s.kind == ts.SyntaxKind.ImportDeclaration); - const nonImports = statements.filter(s => s.kind != ts.SyntaxKind.ImportDeclaration); - - for (let i = 1; i < imports.length; i++) { - const node = imports[i]; - const previous = imports[i - 1]; - - if (previous && previous.kind == ts.SyntaxKind.ImportDeclaration) { - const nodeLine = sourceFile.getLineAndCharacterOfPosition(node.getStart()); - const previousLine = sourceFile.getLineAndCharacterOfPosition(previous.getEnd()); - - if (previousLine.line < nodeLine.line - 1) { - if (nonImports.some(s => s.getStart() > previous.getEnd() - && s.getStart() < node.getStart())) { - // Ignore imports with non-imports statements in between. - continue; - } - - this.addFailureAt( - node.getStart(), - node.getWidth(), - Rule.FAILURE_STRING, - Lint.Replacement.deleteFromTo(previous.getEnd() + 1, node.getStart()), - ); - } - } - } - } -} diff --git a/etc/rules/noGlobalTslintDisableRule.ts b/etc/rules/noGlobalTslintDisableRule.ts deleted file mode 100644 index fea185ec7928..000000000000 --- a/etc/rules/noGlobalTslintDisableRule.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * @license - * 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 - */ -import * as path from 'path'; -import * as Lint from 'tslint'; -import * as ts from 'typescript'; - - -export class Rule extends Lint.Rules.AbstractRule { - public static metadata: Lint.IRuleMetadata = { - ruleName: 'no-global-tslint-disable', - type: 'style', - description: `Ensure global tslint disable are only used for unit tests.`, - rationale: `Some projects want to disallow tslint disable and only use per-line ones.`, - options: null, - optionsDescription: `Not configurable.`, - typescriptOnly: false, - }; - - public static FAILURE_STRING = 'tslint:disable is not allowed in this context.'; - - public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { - return this.applyWithWalker(new Walker(sourceFile, this.getOptions())); - } -} - - -class Walker extends Lint.RuleWalker { - private _findComments(node: ts.Node): ts.CommentRange[] { - return ([] as ts.CommentRange[]).concat( - ts.getLeadingCommentRanges(node.getFullText(), 0) || [], - ts.getTrailingCommentRanges(node.getFullText(), 0) || [], - node.getChildren().reduce((acc, n) => { - return acc.concat(this._findComments(n)); - }, [] as ts.CommentRange[]), - ); - } - - walk(sourceFile: ts.SourceFile) { - super.walk(sourceFile); - - // Ignore spec files. - if (sourceFile.fileName.match(/_spec(_large)?.ts$/)) { - return; - } - // Ignore benchmark files. - if (sourceFile.fileName.match(/_benchmark.ts$/)) { - return; - } - - // TODO(filipesilva): remove this once the files are cleaned up. - // Ignore Angular CLI files files. - if (sourceFile.fileName.includes('/angular-cli-files/')) { - return; - } - - const scriptsPath = path.join(process.cwd(), 'scripts').replace(/\\/g, '/'); - if (sourceFile.fileName.startsWith(scriptsPath)) { - return; - } - - // Find all comment nodes. - const ranges = this._findComments(sourceFile); - ranges.forEach(range => { - const text = sourceFile.getFullText().substring(range.pos, range.end); - let i = text.indexOf('tslint:disable:'); - - while (i != -1) { - this.addFailureAt(range.pos + i + 1, range.pos + i + 15, Rule.FAILURE_STRING); - i = text.indexOf('tslint:disable:', i + 1); - } - }); - } -} diff --git a/etc/rules/singleEofLineRule.ts b/etc/rules/singleEofLineRule.ts deleted file mode 100644 index b2882e43ffb9..000000000000 --- a/etc/rules/singleEofLineRule.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @license - * 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 - */ -import * as Lint from 'tslint'; -import * as ts from 'typescript'; - - -export class Rule extends Lint.Rules.AbstractRule { - public static metadata: Lint.IRuleMetadata = { - ruleName: 'single-eof-line', - type: 'style', - description: `Ensure the file ends with a single new line.`, - rationale: `This is similar to eofline, but ensure an exact count instead of just any new - line.`, - options: null, - optionsDescription: `Two integers indicating minimum and maximum number of new lines.`, - typescriptOnly: false, - }; - - public static FAILURE_STRING = 'You need to have a single blank line at end of file.'; - - public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { - const length = sourceFile.text.length; - if (length === 0) { - // Allow empty files. - return []; - } - - const matchEof = /\r?\n((\r?\n)*)$/.exec(sourceFile.text); - if (!matchEof) { - const lines = sourceFile.getLineStarts(); - const fix = Lint.Replacement.appendText( - length, - sourceFile.text[lines[1] - 2] === '\r' ? '\r\n' : '\n', - ); - - return [ - new Lint.RuleFailure(sourceFile, length, length, Rule.FAILURE_STRING, this.ruleName, fix), - ]; - } else if (matchEof[1]) { - const lines = sourceFile.getLineStarts(); - const fix = Lint.Replacement.replaceFromTo( - matchEof.index, - length, - sourceFile.text[lines[1] - 2] === '\r' ? '\r\n' : '\n', - ); - - return [ - new Lint.RuleFailure(sourceFile, length, length, Rule.FAILURE_STRING, this.ruleName, fix), - ]; - } - - return []; - } -} diff --git a/etc/rules/tsconfig.json b/etc/rules/tsconfig.json deleted file mode 100644 index dce8cbca6f6f..000000000000 --- a/etc/rules/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - // This tsconfig is to help only build the tslint rules instead of the whole project. Makes - // things faster. - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../../dist/etc/rules", - "baseUrl": "" - }, - "exclude": [ - ] -} diff --git a/goldens/BUILD.bazel b/goldens/BUILD.bazel new file mode 100644 index 000000000000..711154f524b1 --- /dev/null +++ b/goldens/BUILD.bazel @@ -0,0 +1,10 @@ +load("@bazel_lib//lib:copy_to_bin.bzl", "copy_to_bin") + +package(default_visibility = ["//visibility:public"]) + +copy_to_bin( + name = "public-api", + srcs = glob([ + "public-api/**/*.md", + ]), +) diff --git a/goldens/circular-deps/packages.json b/goldens/circular-deps/packages.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/goldens/circular-deps/packages.json @@ -0,0 +1 @@ +[] diff --git a/goldens/public-api/angular/build/index.api.md b/goldens/public-api/angular/build/index.api.md new file mode 100644 index 000000000000..ca0a0b34f1ff --- /dev/null +++ b/goldens/public-api/angular/build/index.api.md @@ -0,0 +1,255 @@ +## API Report File for "@angular/build" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BuilderContext } from '@angular-devkit/architect'; +import { BuilderOutput } from '@angular-devkit/architect'; +import type { ConfigOptions } from 'karma'; +import type http from 'node:http'; +import type { OutputFile } from 'esbuild'; +import type { Plugin as Plugin_2 } from 'esbuild'; + +// @public (undocumented) +export interface ApplicationBuilderExtensions { + // (undocumented) + codePlugins?: Plugin_2[]; + // (undocumented) + indexHtmlTransformer?: IndexHtmlTransform; +} + +// @public +export type ApplicationBuilderOptions = { + allowedCommonJsDependencies?: string[]; + aot?: boolean; + appShell?: boolean; + assets?: AssetPattern[]; + baseHref?: string; + browser?: string; + budgets?: Budget[]; + clearScreen?: boolean; + conditions?: string[]; + crossOrigin?: CrossOrigin; + define?: { + [key: string]: string; + }; + deleteOutputPath?: boolean; + deployUrl?: string; + externalDependencies?: string[]; + extractLicenses?: boolean; + fileReplacements?: FileReplacement[]; + i18nDuplicateTranslation?: I18NTranslation; + i18nMissingTranslation?: I18NTranslation; + index?: IndexUnion; + inlineStyleLanguage?: InlineStyleLanguage; + loader?: { + [key: string]: any; + }; + localize?: Localize; + namedChunks?: boolean; + optimization?: OptimizationUnion; + outputHashing?: OutputHashing; + outputMode?: OutputMode; + outputPath?: OutputPathUnion; + poll?: number; + polyfills?: string[]; + prerender?: PrerenderUnion; + preserveSymlinks?: boolean; + progress?: boolean; + scripts?: ScriptElement[]; + security?: Security; + server?: Serv; + serviceWorker?: Serv; + sourceMap?: SourceMapUnion; + ssr?: SsrUnion; + statsJson?: boolean; + stylePreprocessorOptions?: StylePreprocessorOptions; + styles?: StyleElement[]; + subresourceIntegrity?: boolean; + tsConfig: string; + verbose?: boolean; + watch?: boolean; + webWorkerTsConfig?: string; +}; + +// @public +export function buildApplication(options: ApplicationBuilderOptions, context: BuilderContext, extensions?: ApplicationBuilderExtensions): AsyncIterable; + +// @public (undocumented) +export interface BuildOutputAsset { + // (undocumented) + destination: string; + // (undocumented) + source: string; +} + +// @public (undocumented) +export interface BuildOutputFile extends OutputFile { + // (undocumented) + clone: () => BuildOutputFile; + // (undocumented) + readonly size: number; + // (undocumented) + type: BuildOutputFileType; +} + +// @public (undocumented) +export enum BuildOutputFileType { + // (undocumented) + Browser = 0, + // (undocumented) + Media = 1, + // (undocumented) + Root = 4, + // (undocumented) + ServerApplication = 2, + // (undocumented) + ServerRoot = 3 +} + +// @public +export type DevServerBuilderOptions = { + allowedHosts?: AllowedHosts; + buildTarget: string; + define?: { + [key: string]: string; + }; + headers?: { + [key: string]: string; + }; + hmr?: boolean; + host?: string; + inspect?: Inspect; + liveReload?: boolean; + open?: boolean; + poll?: number; + port?: number; + prebundle?: PrebundleUnion; + proxyConfig?: string; + servePath?: string; + ssl?: boolean; + sslCert?: string; + sslKey?: string; + verbose?: boolean; + watch?: boolean; +}; + +// @public +export interface DevServerBuilderOutput extends BuilderOutput { + // (undocumented) + address?: string; + // (undocumented) + baseUrl: string; + // (undocumented) + port?: number; +} + +// @public +export function executeDevServerBuilder(options: DevServerBuilderOptions, context: BuilderContext, extensions?: { + buildPlugins?: Plugin_2[]; + middleware?: ((req: http.IncomingMessage, res: http.ServerResponse, next: (err?: unknown) => void) => void)[]; + indexHtmlTransformer?: IndexHtmlTransform; +}): AsyncIterable; + +// @public +export function executeExtractI18nBuilder(options: ExtractI18nBuilderOptions, context: BuilderContext, extensions?: ApplicationBuilderExtensions): Promise; + +// @public +export function executeKarmaBuilder(options: KarmaBuilderOptions, context: BuilderContext, transforms?: KarmaBuilderTransformsOptions): AsyncIterable; + +// @public +export function executeNgPackagrBuilder(options: NgPackagrBuilderOptions, context: BuilderContext): AsyncIterableIterator; + +// @public +export function executeUnitTestBuilder(options: UnitTestBuilderOptions, context: BuilderContext, extensions?: ApplicationBuilderExtensions): AsyncIterable; + +// @public +export type ExtractI18nBuilderOptions = { + buildTarget?: string; + format?: Format_2; + i18nDuplicateTranslation?: I18NDuplicateTranslation; + outFile?: string; + outputPath?: string; + progress?: boolean; +}; + +// @public +export type KarmaBuilderOptions = { + aot?: boolean; + assets?: AssetPattern_2[]; + browsers?: Browsers; + codeCoverage?: boolean; + codeCoverageExclude?: string[]; + define?: { + [key: string]: string; + }; + exclude?: string[]; + externalDependencies?: string[]; + fileReplacements?: FileReplacement_2[]; + include?: string[]; + inlineStyleLanguage?: InlineStyleLanguage_2; + karmaConfig?: string; + loader?: { + [key: string]: any; + }; + main?: string; + poll?: number; + polyfills?: string[]; + preserveSymlinks?: boolean; + progress?: boolean; + reporters?: string[]; + scripts?: ScriptElement_2[]; + sourceMap?: SourceMapUnion_2; + stylePreprocessorOptions?: StylePreprocessorOptions_2; + styles?: StyleElement_2[]; + tsConfig: string; + watch?: boolean; + webWorkerTsConfig?: string; +}; + +// @public +export type NgPackagrBuilderOptions = { + poll?: number; + project?: string; + tsConfig?: string; + watch?: boolean; +}; + +// @public +export type UnitTestBuilderOptions = { + browserViewport?: string; + browsers?: [string, ...string[]]; + buildTarget?: string; + coverage?: boolean; + coverageExclude?: string[]; + coverageInclude?: string[]; + coverageReporters?: SchemaCoverageReporter[]; + coverageThresholds?: CoverageThresholds; + coverageWatermarks?: CoverageWatermarks; + debug?: boolean; + dumpVirtualFiles?: boolean; + exclude?: string[]; + filter?: string; + headless?: boolean; + include?: string[]; + isolate?: boolean; + listTests?: boolean; + outputFile?: string; + polyfills?: string[]; + progress?: boolean; + providersFile?: string; + quiet?: boolean; + reporters?: SchemaReporter[]; + runner?: Runner; + runnerConfig?: RunnerConfig; + setupFiles?: string[]; + splitting?: boolean; + tsConfig?: string; + ui?: boolean; + watch?: boolean; +}; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular/ssr/index.api.md b/goldens/public-api/angular/ssr/index.api.md new file mode 100644 index 000000000000..852187d5290b --- /dev/null +++ b/goldens/public-api/angular/ssr/index.api.md @@ -0,0 +1,102 @@ +## API Report File for "@angular/ssr" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { DefaultExport } from '@angular/router'; +import { EnvironmentProviders } from '@angular/core'; +import { InjectionToken } from '@angular/core'; +import { Provider } from '@angular/core'; +import { Type } from '@angular/core'; + +// @public +export class AngularAppEngine { + constructor(options?: AngularAppEngineOptions); + handle(request: Request, requestContext?: unknown): Promise; + static ɵallowStaticRouteRender: boolean; + static ɵdisableAllowedHostsCheck: boolean; + static ɵhooks: Hooks; +} + +// @public +export interface AngularAppEngineOptions { + allowedHosts?: readonly string[]; + trustProxyHeaders?: boolean | readonly string[]; +} + +// @public +export function createRequestHandler(handler: RequestHandlerFunction): RequestHandlerFunction; + +// @public +export const IS_DISCOVERING_ROUTES: InjectionToken; + +// @public +export enum PrerenderFallback { + Client = 1, + None = 2, + Server = 0 +} + +// @public +export function provideServerRendering(...features: ServerRenderingFeature[]): EnvironmentProviders; + +// @public +export function provideServerRendering(options: ServerRenderingOptions, ...features: ServerRenderingFeature[]): EnvironmentProviders; + +// @public +export enum RenderMode { + Client = 1, + Prerender = 2, + Server = 0 +} + +// @public +export type RequestHandlerFunction = (request: Request) => Promise | null | Response; + +// @public +export interface ServerRenderingOptions { + maxResponseBodySize: number; +} + +// @public +export type ServerRoute = ServerRouteClient | ServerRoutePrerender | ServerRoutePrerenderWithParams | ServerRouteServer; + +// @public +export interface ServerRouteClient extends ServerRouteCommon { + renderMode: RenderMode.Client; +} + +// @public +export interface ServerRouteCommon { + headers?: Record; + path: string; + status?: number; +} + +// @public +export interface ServerRoutePrerender extends Omit { + fallback?: never; + renderMode: RenderMode.Prerender; +} + +// @public +export interface ServerRoutePrerenderWithParams extends Omit { + fallback?: PrerenderFallback; + getPrerenderParams: () => Promise[]>; +} + +// @public +export interface ServerRouteServer extends ServerRouteCommon { + renderMode: RenderMode.Server; +} + +// @public +export function withAppShell(component: Type | (() => Promise | DefaultExport>>)): ServerRenderingFeature; + +// @public +export function withRoutes(routes: ServerRoute[]): ServerRenderingFeature; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular/ssr/node/index.api.md b/goldens/public-api/angular/ssr/node/index.api.md new file mode 100644 index 000000000000..06b61ab1fdcb --- /dev/null +++ b/goldens/public-api/angular/ssr/node/index.api.md @@ -0,0 +1,71 @@ +## API Report File for "@angular/ssr_node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApplicationRef } from '@angular/core'; +import { BootstrapContext } from '@angular/platform-browser'; +import { Http2ServerRequest } from 'node:http2'; +import { Http2ServerResponse } from 'node:http2'; +import { IncomingMessage } from 'node:http'; +import { ServerResponse } from 'node:http'; +import { StaticProvider } from '@angular/core'; +import { Type } from '@angular/core'; + +// @public +export class AngularNodeAppEngine { + constructor(options?: AngularNodeAppEngineOptions); + handle(request: IncomingMessage | Http2ServerRequest | Request, requestContext?: unknown): Promise; +} + +// @public +export interface AngularNodeAppEngineOptions extends AngularAppEngineOptions { +} + +// @public @deprecated +export class CommonEngine { + constructor(options?: CommonEngineOptions | undefined); + render(opts: CommonEngineRenderOptions): Promise; +} + +// @public @deprecated (undocumented) +export interface CommonEngineOptions { + allowedHosts?: readonly string[]; + bootstrap?: Type<{}> | ((context: BootstrapContext) => Promise); + enablePerformanceProfiler?: boolean; + providers?: StaticProvider[]; +} + +// @public @deprecated (undocumented) +export interface CommonEngineRenderOptions { + bootstrap?: Type<{}> | ((context: BootstrapContext) => Promise); + // (undocumented) + document?: string; + // (undocumented) + documentFilePath?: string; + inlineCriticalCss?: boolean; + providers?: StaticProvider[]; + publicPath?: string; + // (undocumented) + url?: string; +} + +// @public +export function createNodeRequestHandler(handler: T): T; + +// @public +export function createWebRequestFromNodeRequest(nodeRequest: IncomingMessage | Http2ServerRequest, trustProxyHeaders?: boolean | readonly string[]): Request; + +// @public +export function isMainModule(url: string): boolean; + +// @public +export type NodeRequestHandlerFunction = (req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => Promise | void; + +// @public +export function writeResponseToNodeResponse(source: Response, destination: ServerResponse | Http2ServerResponse): Promise; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/architect/index.api.md b/goldens/public-api/angular_devkit/architect/index.api.md new file mode 100644 index 000000000000..4d486e84e5aa --- /dev/null +++ b/goldens/public-api/angular_devkit/architect/index.api.md @@ -0,0 +1,553 @@ +## API Report File for "@angular-devkit/architect" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BaseException } from '@angular-devkit/core'; +import { json } from '@angular-devkit/core'; +import { JsonObject } from '@angular-devkit/core'; +import { JsonValue } from '@angular-devkit/core'; +import { logging } from '@angular-devkit/core'; +import { Observable } from 'rxjs'; +import { ObservableInput } from 'rxjs'; +import { Observer } from 'rxjs'; +import { schema } from '@angular-devkit/core'; + +// @public (undocumented) +export class Architect { + constructor(_host: ArchitectHost, registry?: json.schema.SchemaRegistry, additionalJobRegistry?: Registry); + // (undocumented) + has(name: JobName): Observable; + // (undocumented) + scheduleBuilder(name: string, options: json.JsonObject, scheduleOptions?: ScheduleOptions): Promise; + // (undocumented) + scheduleTarget(target: Target, overrides?: json.JsonObject, scheduleOptions?: ScheduleOptions): Promise; +} + +// @public +export interface Builder { + // (undocumented) + [BuilderSymbol]: true; + // (undocumented) + [BuilderVersionSymbol]: string; + // (undocumented) + __OptionT: OptionT; + // (undocumented) + handler: JobHandler; +} + +// @public +export interface BuilderContext { + addTeardown(teardown: () => Promise | void): void; + builder: BuilderInfo; + currentDirectory: string; + getBuilderNameForTarget(target: Target): Promise; + // (undocumented) + getProjectMetadata(projectName: string): Promise; + // (undocumented) + getProjectMetadata(target: Target): Promise; + getTargetOptions(target: Target): Promise; + id: number; + logger: logging.LoggerApi; + reportProgress(current: number, total?: number, status?: string): void; + reportRunning(): void; + reportStatus(status: string): void; + scheduleBuilder(builderName: string, options?: json.JsonObject, scheduleOptions?: ScheduleOptions_2): Promise; + scheduleTarget(target: Target, overrides?: json.JsonObject, scheduleOptions?: ScheduleOptions_2): Promise; + target?: Target; + validateOptions(options: json.JsonObject, builderName: string): Promise; + workspaceRoot: string; +} + +// @public +export interface BuilderHandlerFn { + (input: A, context: BuilderContext): BuilderOutputLike; +} + +// @public +export type BuilderInfo = json.JsonObject & { + builderName: string; + description: string; + optionSchema: json.schema.JsonSchema; +}; + +// @public +export type BuilderInput = json.JsonObject & Schema; + +// @public (undocumented) +export type BuilderOutput = json.JsonObject & Schema_2; + +// @public +export type BuilderOutputLike = ObservableInput | BuilderOutput; + +// @public (undocumented) +export type BuilderProgress = json.JsonObject & Schema_3 & TypedBuilderProgress; + +// @public +export type BuilderProgressReport = BuilderProgress & { + target?: Target; + builder: BuilderInfo; +}; + +// @public (undocumented) +export enum BuilderProgressState { + // (undocumented) + Error = "error", + // (undocumented) + Running = "running", + // (undocumented) + Stopped = "stopped", + // (undocumented) + Waiting = "waiting" +} + +// @public (undocumented) +export type BuilderRegistry = Registry; + +// @public +export interface BuilderRun { + id: number; + info: BuilderInfo; + lastOutput: Promise; + output: Observable; + progress: Observable; + result: Promise; + stop(): Promise; +} + +// @public (undocumented) +class ChannelAlreadyExistException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export function createBuilder(fn: BuilderHandlerFn): Builder; + +// @public +function createDispatcher(options?: Partial>): JobDispatcher; + +// @public +function createJobFactory(loader: () => Promise>, options?: Partial): JobHandler; + +// @public +function createJobHandler(fn: SimpleJobHandlerFn, options?: Partial): JobHandler; + +// @public +function createLoggerJob(job: JobHandler, logger: logging.LoggerApi): JobHandler; + +// @public +class FallbackRegistry implements Registry { + constructor(_fallbacks?: Registry[]); + // (undocumented) + addFallback(registry: Registry): void; + // (undocumented) + protected _fallbacks: Registry[]; + // (undocumented) + get(name: JobName): Observable | null>; +} + +// @public (undocumented) +export function fromAsyncIterable(iterable: AsyncIterable): Observable; + +// @public (undocumented) +export function isBuilderOutput(obj: any): obj is BuilderOutput; + +// @public (undocumented) +function isJobHandler(value: unknown): value is JobHandler; + +// @public +interface Job { + readonly argument: ArgumentT; + readonly description: Observable; + getChannel(name: string, schema?: schema.JsonSchema): Observable; + readonly inboundBus: Observer>; + readonly input: Observer; + readonly outboundBus: Observable>; + readonly output: Observable; + ping(): Observable; + readonly state: JobState; + stop(): void; +} + +// @public (undocumented) +class JobArgumentSchemaValidationError extends schema.SchemaValidationException { + constructor(errors?: schema.SchemaValidatorError[]); +} + +// @public +interface JobDescription extends JsonObject { + // (undocumented) + readonly argument: DeepReadonly; + // (undocumented) + readonly input: DeepReadonly; + // (undocumented) + readonly name: JobName; + // (undocumented) + readonly output: DeepReadonly; +} + +// @public +interface JobDispatcher extends JobHandler { + addConditionalJob(predicate: (args: A) => boolean, name: string): void; + setDefaultJob(name: JobName | null | JobHandler): void; +} + +// @public (undocumented) +class JobDoesNotExistException extends BaseException { + constructor(name: JobName); +} + +// @public +interface JobHandler { + // (undocumented) + (argument: ArgT, context: JobHandlerContext): Observable>; + // (undocumented) + jobDescription: Partial; +} + +// @public +interface JobHandlerContext { + // (undocumented) + readonly dependencies: Job[]; + // (undocumented) + readonly description: JobDescription; + // (undocumented) + readonly inboundBus: Observable>; + // (undocumented) + readonly scheduler: Scheduler_2; +} + +// @public (undocumented) +type JobInboundMessage = JobInboundMessagePing | JobInboundMessageStop | JobInboundMessageInput; + +// @public +interface JobInboundMessageBase extends JsonObject { + readonly kind: JobInboundMessageKind; +} + +// @public +interface JobInboundMessageInput extends JobInboundMessageBase { + // (undocumented) + readonly kind: JobInboundMessageKind.Input; + readonly value: InputT; +} + +// @public +enum JobInboundMessageKind { + // (undocumented) + Input = "in", + // (undocumented) + Ping = "ip", + // (undocumented) + Stop = "is" +} + +// @public +interface JobInboundMessagePing extends JobInboundMessageBase { + readonly id: number; + // (undocumented) + readonly kind: JobInboundMessageKind.Ping; +} + +// @public (undocumented) +class JobInboundMessageSchemaValidationError extends schema.SchemaValidationException { + constructor(errors?: schema.SchemaValidatorError[]); +} + +// @public +interface JobInboundMessageStop extends JobInboundMessageBase { + // (undocumented) + readonly kind: JobInboundMessageKind.Stop; +} + +// @public +type JobName = string; + +// @public (undocumented) +class JobNameAlreadyRegisteredException extends BaseException { + constructor(name: JobName); +} + +// @public +type JobOutboundMessage = JobOutboundMessageOnReady | JobOutboundMessageStart | JobOutboundMessageOutput | JobOutboundMessageChannelCreate | JobOutboundMessageChannelMessage | JobOutboundMessageChannelError | JobOutboundMessageChannelComplete | JobOutboundMessageEnd | JobOutboundMessagePong; + +// @public +interface JobOutboundMessageBase { + readonly description: JobDescription; + readonly kind: JobOutboundMessageKind; +} + +// @public +interface JobOutboundMessageChannelBase extends JobOutboundMessageBase { + readonly name: string; +} + +// @public +interface JobOutboundMessageChannelComplete extends JobOutboundMessageChannelBase { + // (undocumented) + readonly kind: JobOutboundMessageKind.ChannelComplete; +} + +// @public +interface JobOutboundMessageChannelCreate extends JobOutboundMessageChannelBase { + // (undocumented) + readonly kind: JobOutboundMessageKind.ChannelCreate; +} + +// @public +interface JobOutboundMessageChannelError extends JobOutboundMessageChannelBase { + readonly error: JsonValue; + // (undocumented) + readonly kind: JobOutboundMessageKind.ChannelError; +} + +// @public +interface JobOutboundMessageChannelMessage extends JobOutboundMessageChannelBase { + // (undocumented) + readonly kind: JobOutboundMessageKind.ChannelMessage; + readonly message: JsonValue; +} + +// @public +interface JobOutboundMessageEnd extends JobOutboundMessageBase { + // (undocumented) + readonly kind: JobOutboundMessageKind.End; +} + +// @public +enum JobOutboundMessageKind { + // (undocumented) + ChannelComplete = "cc", + // (undocumented) + ChannelCreate = "cn", + // (undocumented) + ChannelError = "ce", + // (undocumented) + ChannelMessage = "cm", + // (undocumented) + End = "e", + // (undocumented) + OnReady = "c", + // (undocumented) + Output = "o", + // (undocumented) + Pong = "p", + // (undocumented) + Start = "s" +} + +// @public +interface JobOutboundMessageOnReady extends JobOutboundMessageBase { + // (undocumented) + readonly kind: JobOutboundMessageKind.OnReady; +} + +// @public +interface JobOutboundMessageOutput extends JobOutboundMessageBase { + // (undocumented) + readonly kind: JobOutboundMessageKind.Output; + readonly value: OutputT; +} + +// @public +interface JobOutboundMessagePong extends JobOutboundMessageBase { + readonly id: number; + // (undocumented) + readonly kind: JobOutboundMessageKind.Pong; +} + +// @public +interface JobOutboundMessageStart extends JobOutboundMessageBase { + // (undocumented) + readonly kind: JobOutboundMessageKind.Start; +} + +// @public (undocumented) +class JobOutputSchemaValidationError extends schema.SchemaValidationException { + constructor(errors?: schema.SchemaValidatorError[]); +} + +declare namespace jobs { + export { + strategy, + isJobHandler, + JobName, + JobHandler, + JobHandlerContext, + JobDescription, + JobInboundMessageKind, + JobInboundMessageBase, + JobInboundMessagePing, + JobInboundMessageStop, + JobInboundMessageInput, + JobInboundMessage, + JobOutboundMessageKind, + JobOutboundMessageBase, + JobOutboundMessageOnReady, + JobOutboundMessageStart, + JobOutboundMessageOutput, + JobOutboundMessageChannelBase, + JobOutboundMessageChannelMessage, + JobOutboundMessageChannelError, + JobOutboundMessageChannelCreate, + JobOutboundMessageChannelComplete, + JobOutboundMessageEnd, + JobOutboundMessagePong, + JobOutboundMessage, + JobState, + Job, + ScheduleJobOptions, + Registry, + Scheduler_2 as Scheduler, + createJobHandler, + createJobFactory, + createLoggerJob, + ChannelAlreadyExistException, + SimpleJobHandlerContext, + SimpleJobHandlerFn, + JobNameAlreadyRegisteredException, + JobDoesNotExistException, + createDispatcher, + JobDispatcher, + FallbackRegistry, + RegisterJobOptions, + SimpleJobRegistry, + JobArgumentSchemaValidationError, + JobInboundMessageSchemaValidationError, + JobOutputSchemaValidationError, + SimpleScheduler + } +} +export { jobs } + +// @public +enum JobState { + Ended = "ended", + Errored = "errored", + Queued = "queued", + Ready = "ready", + Started = "started" +} + +// @public (undocumented) +type JobStrategy = (handler: JobHandler, options?: Partial>) => JobHandler; + +// @public +function memoize(replayMessages?: boolean): JobStrategy; + +// @public +interface RegisterJobOptions extends Partial { +} + +// @public (undocumented) +interface Registry { + get(name: JobName): Observable | null>; +} + +// @public +function reuse(replayMessages?: boolean): JobStrategy; + +// @public +interface ScheduleJobOptions { + dependencies?: Job | Job[]; +} + +// @public (undocumented) +export interface ScheduleOptions { + // (undocumented) + logger?: logging.Logger; +} + +// @public +interface Scheduler_2 { + getDescription(name: JobName): Observable; + has(name: JobName): Observable; + pause(): () => void; + schedule(name: JobName, argument: A, options?: ScheduleJobOptions): Job; +} + +// @public +export function scheduleTargetAndForget(context: BuilderContext, target: Target, overrides?: json.JsonObject, scheduleOptions?: ScheduleOptions_2): Observable; + +// @public +function serialize(): JobStrategy; + +// @public +interface SimpleJobHandlerContext extends JobHandlerContext { + // (undocumented) + addTeardown(teardown: () => Promise | void): void; + // (undocumented) + createChannel: (name: string) => Observer; + // (undocumented) + input: Observable; +} + +// @public +type SimpleJobHandlerFn = (input: A, context: SimpleJobHandlerContext) => O | Promise | Observable; + +// @public +class SimpleJobRegistry implements Registry { + // (undocumented) + get(name: JobName): Observable | null>; + getJobNames(): JobName[]; + register(name: JobName, handler: JobHandler, options?: RegisterJobOptions): void; + register(handler: JobHandler, options?: RegisterJobOptions & { + name: string; + }): void; + // (undocumented) + protected _register(name: JobName, handler: JobHandler, options: RegisterJobOptions): void; +} + +// @public +class SimpleScheduler implements Scheduler_2 { + constructor(_jobRegistry: Registry, _schemaRegistry?: schema.SchemaRegistry); + getDescription(name: JobName): Observable; + has(name: JobName): Observable; + // (undocumented) + protected _jobRegistry: Registry; + pause(): () => void; + schedule(name: JobName, argument: A, options?: ScheduleJobOptions): Job; + // (undocumented) + protected _scheduleJob(name: JobName, argument: A, options: ScheduleJobOptions, waitable: Observable): Job; + // (undocumented) + protected _schemaRegistry: schema.SchemaRegistry; +} + +declare namespace strategy { + export { + serialize, + reuse, + memoize, + JobStrategy + } +} + +// @public (undocumented) +export type Target = json.JsonObject & Target_2; + +// @public +export function targetFromTargetString(specifier: string, abbreviatedProjectName?: string, abbreviatedTargetName?: string): Target; + +// @public +export function targetStringFromTarget(input: Target): string; + +// @public +export type TypedBuilderProgress = { + state: BuilderProgressState.Stopped; +} | { + state: BuilderProgressState.Error; + error: json.JsonValue; +} | { + state: BuilderProgressState.Waiting; + status?: string; +} | { + state: BuilderProgressState.Running; + status?: string; + current: number; + total?: number; +}; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/architect/node/index.api.md b/goldens/public-api/angular_devkit/architect/node/index.api.md new file mode 100644 index 000000000000..059423576f71 --- /dev/null +++ b/goldens/public-api/angular_devkit/architect/node/index.api.md @@ -0,0 +1,73 @@ +## API Report File for "@angular-devkit/architect_node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BaseException } from '@angular-devkit/core'; +import { json } from '@angular-devkit/core'; +import { JsonObject } from '@angular-devkit/core'; +import { JsonValue } from '@angular-devkit/core'; +import { logging } from '@angular-devkit/core'; +import { Observable } from 'rxjs'; +import { Observer } from 'rxjs'; +import { schema } from '@angular-devkit/core'; +import { workspaces } from '@angular-devkit/core'; + +declare namespace jobs { + export { + NodeModuleJobRegistry + } +} +export { jobs } + +// @public (undocumented) +class NodeModuleJobRegistry implements jobs_2.Registry { + get(name: jobs_2.JobName): Observable | null>; + // (undocumented) + protected _resolve(name: string): string | null; +} + +// @public (undocumented) +export type NodeModulesBuilderInfo = BuilderInfo & { + import: string; +}; + +// @public (undocumented) +export interface WorkspaceHost { + // (undocumented) + getBuilderName(project: string, target: string): Promise; + // (undocumented) + getDefaultConfigurationName(project: string, target: string): Promise; + // (undocumented) + getMetadata(project: string): Promise; + // (undocumented) + getOptions(project: string, target: string, configuration?: string): Promise; + // (undocumented) + hasTarget(project: string, target: string): Promise; +} + +// @public (undocumented) +export class WorkspaceNodeModulesArchitectHost implements ArchitectHost { + constructor(workspaceHost: WorkspaceHost, _root: string); + constructor(workspace: workspaces.WorkspaceDefinition, _root: string); + // (undocumented) + getBuilderNameForTarget(target: Target_2): Promise; + // (undocumented) + getCurrentDirectory(): Promise; + // (undocumented) + getOptionsForTarget(target: Target_2): Promise; + // (undocumented) + getProjectMetadata(target: Target_2 | string): Promise; + // (undocumented) + getWorkspaceRoot(): Promise; + // (undocumented) + loadBuilder(info: NodeModulesBuilderInfo): Promise; + resolveBuilder(builderStr: string, basePath?: string, seenBuilders?: Set): Promise; + // (undocumented) + protected _root: string; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/architect/testing/index.api.md b/goldens/public-api/angular_devkit/architect/testing/index.api.md new file mode 100644 index 000000000000..23cc6fe8cd7a --- /dev/null +++ b/goldens/public-api/angular_devkit/architect/testing/index.api.md @@ -0,0 +1,76 @@ +## API Report File for "@angular-devkit/architect_testing" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { json } from '@angular-devkit/core'; +import { JsonObject } from '@angular-devkit/core'; +import { JsonValue } from '@angular-devkit/core'; +import { NodeJsSyncHost } from '@angular-devkit/core/node'; +import { Observable } from 'rxjs'; +import { Observer } from 'rxjs'; +import { Path } from '@angular-devkit/core'; +import { PathFragment } from '@angular-devkit/core'; +import { schema } from '@angular-devkit/core'; +import { Stats } from 'node:fs'; +import { virtualFs } from '@angular-devkit/core'; + +// @public (undocumented) +export class TestingArchitectHost implements ArchitectHost { + constructor(workspaceRoot?: string, currentDirectory?: string, _backendHost?: ArchitectHost | null); + // (undocumented) + addBuilder(builderName: string, builder: Builder, description?: string, optionSchema?: json.schema.JsonSchema): void; + // (undocumented) + addBuilderFromPackage(packageName: string): Promise; + // (undocumented) + addTarget(target: Target, builderName: string, options?: json.JsonObject): void; + // (undocumented) + currentDirectory: string; + // (undocumented) + getBuilderNameForTarget(target: Target): Promise; + // (undocumented) + getCurrentDirectory(): Promise; + // (undocumented) + getOptionsForTarget(target: Target): Promise; + // (undocumented) + getProjectMetadata(target: Target | string): Promise; + // (undocumented) + getWorkspaceRoot(): Promise; + // (undocumented) + loadBuilder(info: BuilderInfo): Promise; + resolveBuilder(builderName: string): Promise; + // (undocumented) + workspaceRoot: string; +} + +// @public @deprecated (undocumented) +export class TestProjectHost extends NodeJsSyncHost { + constructor(_templateRoot: Path); + // (undocumented) + appendToFile(path: string, str: string): void; + // (undocumented) + copyFile(from: string, to: string): void; + // (undocumented) + fileMatchExists(dir: string, regex: RegExp): PathFragment | undefined; + // (undocumented) + initialize(): Observable; + // (undocumented) + replaceInFile(path: string, match: RegExp | string, replacement: string): void; + // (undocumented) + restore(): Observable; + // (undocumented) + root(): Path; + // (undocumented) + scopedSync(): virtualFs.SyncDelegateHost; + // (undocumented) + protected _templateRoot: Path; + // (undocumented) + writeMultipleFiles(files: { + [path: string]: string | ArrayBufferLike | Buffer; + }): void; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/build_angular/index.api.md b/goldens/public-api/angular_devkit/build_angular/index.api.md new file mode 100644 index 000000000000..a7747bda9da2 --- /dev/null +++ b/goldens/public-api/angular_devkit/build_angular/index.api.md @@ -0,0 +1,355 @@ +## API Report File for "@angular-devkit/build-angular" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApplicationBuilderOptions } from '@angular/build'; +import { buildApplication } from '@angular/build'; +import { BuilderContext } from '@angular-devkit/architect'; +import { BuilderOutput } from '@angular-devkit/architect'; +import type { ConfigOptions } from 'karma'; +import { Configuration } from 'webpack'; +import { DevServerBuilderOutput } from '@angular/build'; +import type http from 'node:http'; +import { IndexHtmlTransform } from '@angular/build/private'; +import { Observable } from 'rxjs'; +import type { Plugin as Plugin_2 } from 'esbuild'; +import webpack from 'webpack'; +import { WebpackLoggingCallback } from '@angular-devkit/build-webpack'; + +export { ApplicationBuilderOptions } + +// @public (undocumented) +export type AssetPattern = AssetPatternObject | string; + +// @public (undocumented) +export type AssetPatternObject = { + followSymlinks?: boolean; + glob: string; + ignore?: string[]; + input: string; + output?: string; +}; + +// @public +export type BrowserBuilderOptions = { + allowedCommonJsDependencies?: string[]; + aot?: boolean; + assets?: AssetPattern[]; + baseHref?: string; + budgets?: Budget[]; + buildOptimizer?: boolean; + commonChunk?: boolean; + crossOrigin?: CrossOrigin; + deleteOutputPath?: boolean; + deployUrl?: string; + extractLicenses?: boolean; + fileReplacements?: FileReplacement[]; + i18nDuplicateTranslation?: I18NTranslation; + i18nMissingTranslation?: I18NTranslation; + index: IndexUnion; + inlineStyleLanguage?: InlineStyleLanguage_2; + localize?: Localize; + main: string; + namedChunks?: boolean; + ngswConfigPath?: string; + optimization?: OptimizationUnion; + outputHashing?: OutputHashing; + outputPath: string; + poll?: number; + polyfills?: Polyfills_2; + preserveSymlinks?: boolean; + progress?: boolean; + resourcesOutputPath?: string; + scripts?: ScriptElement_2[]; + serviceWorker?: boolean; + sourceMap?: SourceMapUnion; + statsJson?: boolean; + stylePreprocessorOptions?: StylePreprocessorOptions; + styles?: StyleElement_2[]; + subresourceIntegrity?: boolean; + tsConfig: string; + vendorChunk?: boolean; + verbose?: boolean; + watch?: boolean; + webWorkerTsConfig?: string; +}; + +// @public +export type BrowserBuilderOutput = BuilderOutput & { + stats: BuildEventStats; + baseOutputPath: string; + outputs: { + locale?: string; + path: string; + baseHref?: string; + }[]; +}; + +// @public (undocumented) +export type Budget = { + baseline?: string; + error?: string; + maximumError?: string; + maximumWarning?: string; + minimumError?: string; + minimumWarning?: string; + name?: string; + type: Type; + warning?: string; +}; + +export { buildApplication } + +// @public +export enum CrossOrigin { + // (undocumented) + Anonymous = "anonymous", + // (undocumented) + None = "none", + // (undocumented) + UseCredentials = "use-credentials" +} + +// @public +export type DevServerBuilderOptions = { + allowedHosts?: string[]; + buildTarget: string; + disableHostCheck?: boolean; + forceEsbuild?: boolean; + headers?: { + [key: string]: string; + }; + hmr?: boolean; + host?: string; + inspect?: Inspect; + liveReload?: boolean; + open?: boolean; + poll?: number; + port?: number; + prebundle?: PrebundleUnion; + proxyConfig?: string; + publicHost?: string; + servePath?: string; + ssl?: boolean; + sslCert?: string; + sslKey?: string; + verbose?: boolean; + watch?: boolean; +}; + +export { DevServerBuilderOutput } + +// @public +export function executeBrowserBuilder(options: BrowserBuilderOptions, context: BuilderContext, transforms?: { + webpackConfiguration?: ExecutionTransformer; + logging?: WebpackLoggingCallback; + indexHtml?: IndexHtmlTransform; +}): Observable; + +// @public +export function executeDevServerBuilder(options: DevServerBuilderOptions, context: BuilderContext, transforms?: { + webpackConfiguration?: ExecutionTransformer; + logging?: WebpackLoggingCallback; + indexHtml?: IndexHtmlTransform; +}, extensions?: { + buildPlugins?: Plugin_2[]; + middleware?: ((req: http.IncomingMessage, res: http.ServerResponse, next: (err?: unknown) => void) => void)[]; + builderSelector?: (info: BuilderSelectorInfo, logger: BuilderContext['logger']) => string; +}): Observable; + +// @public +export function executeExtractI18nBuilder(options: ExtractI18nBuilderOptions, context: BuilderContext, transforms?: { + webpackConfiguration?: ExecutionTransformer; +}): Promise; + +// @public +export function executeKarmaBuilder(options: KarmaBuilderOptions, context: BuilderContext, transforms?: { + webpackConfiguration?: ExecutionTransformer; + karmaOptions?: (options: KarmaConfigOptions) => KarmaConfigOptions; +}): Observable; + +// @public +export function executeNgPackagrBuilder(options: NgPackagrBuilderOptions, context: BuilderContext): Observable; + +// @public +export function executeServerBuilder(options: ServerBuilderOptions, context: BuilderContext, transforms?: { + webpackConfiguration?: ExecutionTransformer; +}): Observable; + +// @public (undocumented) +export function executeSSRDevServerBuilder(options: SSRDevServerBuilderOptions, context: BuilderContext): Observable; + +// @public +export type ExecutionTransformer = (input: T) => T | Promise; + +// @public +export type ExtractI18nBuilderOptions = { + buildTarget?: string; + format?: Format; + i18nDuplicateTranslation?: I18NDuplicateTranslation; + outFile?: string; + outputPath?: string; + progress?: boolean; +}; + +// @public (undocumented) +export type FileReplacement = { + replace?: string; + replaceWith?: string; + src?: string; + with?: string; +}; + +// @public +export type KarmaBuilderOptions = { + aot?: boolean; + assets?: AssetPattern_2[]; + browsers?: Browsers; + builderMode?: BuilderMode; + codeCoverage?: boolean; + codeCoverageExclude?: string[]; + exclude?: string[]; + fileReplacements?: FileReplacement_2[]; + include?: string[]; + inlineStyleLanguage?: InlineStyleLanguage; + karmaConfig?: string; + main?: string; + poll?: number; + polyfills?: Polyfills; + preserveSymlinks?: boolean; + progress?: boolean; + reporters?: string[]; + scripts?: ScriptElement[]; + sourceMap?: SourceMapUnion_2; + stylePreprocessorOptions?: StylePreprocessorOptions_2; + styles?: StyleElement[]; + tsConfig: string; + watch?: boolean; + webWorkerTsConfig?: string; +}; + +// @public (undocumented) +export type KarmaConfigOptions = ConfigOptions & { + buildWebpack?: unknown; + configFile?: string; +}; + +// @public +export type NgPackagrBuilderOptions = { + poll?: number; + project: string; + tsConfig?: string; + watch?: boolean; +}; + +// @public (undocumented) +export type OptimizationObject = { + fonts?: FontsUnion; + scripts?: boolean; + styles?: StylesUnion; +}; + +// @public +export type OptimizationUnion = boolean | OptimizationObject; + +// @public +export enum OutputHashing { + // (undocumented) + All = "all", + // (undocumented) + Bundles = "bundles", + // (undocumented) + Media = "media", + // (undocumented) + None = "none" +} + +// @public (undocumented) +export type ServerBuilderOptions = { + assets?: AssetPattern_3[]; + buildOptimizer?: boolean; + deleteOutputPath?: boolean; + deployUrl?: string; + externalDependencies?: string[]; + extractLicenses?: boolean; + fileReplacements?: FileReplacement_3[]; + i18nDuplicateTranslation?: I18NTranslation_2; + i18nMissingTranslation?: I18NTranslation_2; + inlineStyleLanguage?: InlineStyleLanguage_3; + localize?: Localize_2; + main: string; + namedChunks?: boolean; + optimization?: OptimizationUnion_2; + outputHashing?: OutputHashing_2; + outputPath: string; + poll?: number; + preserveSymlinks?: boolean; + progress?: boolean; + resourcesOutputPath?: string; + sourceMap?: SourceMapUnion_3; + statsJson?: boolean; + stylePreprocessorOptions?: StylePreprocessorOptions_3; + tsConfig: string; + vendorChunk?: boolean; + verbose?: boolean; + watch?: boolean; +}; + +// @public +export type ServerBuilderOutput = BuilderOutput & { + baseOutputPath: string; + outputPath: string; + outputs: { + locale?: string; + path: string; + }[]; +}; + +// @public (undocumented) +export type SourceMapObject = { + hidden?: boolean; + scripts?: boolean; + styles?: boolean; + vendor?: boolean; +}; + +// @public +export type SourceMapUnion = boolean | SourceMapObject; + +// @public (undocumented) +export type SSRDevServerBuilderOptions = Schema; + +// @public (undocumented) +export type SSRDevServerBuilderOutput = BuilderOutput & { + baseUrl?: string; + port?: string; +}; + +// @public +export type StylePreprocessorOptions = { + includePaths?: string[]; +}; + +// @public +export enum Type { + // (undocumented) + All = "all", + // (undocumented) + AllScript = "allScript", + // (undocumented) + Any = "any", + // (undocumented) + AnyComponentStyle = "anyComponentStyle", + // (undocumented) + AnyScript = "anyScript", + // (undocumented) + Bundle = "bundle", + // (undocumented) + Initial = "initial" +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/build_webpack/index.api.md b/goldens/public-api/angular_devkit/build_webpack/index.api.md new file mode 100644 index 000000000000..3e7ebf07873b --- /dev/null +++ b/goldens/public-api/angular_devkit/build_webpack/index.api.md @@ -0,0 +1,80 @@ +## API Report File for "@angular-devkit/build-webpack" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BuilderContext } from '@angular-devkit/architect'; +import { BuilderOutput } from '@angular-devkit/architect'; +import type { Configuration } from 'webpack-dev-server'; +import { Observable } from 'rxjs'; +import type webpack from 'webpack'; +import type WebpackDevServer from 'webpack-dev-server'; + +// @public (undocumented) +export type BuildResult = BuilderOutput & { + emittedFiles?: EmittedFiles[]; + webpackStats?: webpack.StatsCompilation; + outputPath: string; +}; + +// @public (undocumented) +export type DevServerBuildOutput = BuildResult & { + port: number; + family: string; + address: string; +}; + +// @public (undocumented) +export interface EmittedFiles { + // (undocumented) + asset?: boolean; + // (undocumented) + extension: string; + // (undocumented) + file: string; + // (undocumented) + id?: string; + // (undocumented) + initial: boolean; + // (undocumented) + name?: string; +} + +// @public @deprecated (undocumented) +export function runWebpack(config: webpack.Configuration, context: BuilderContext, options?: { + logging?: WebpackLoggingCallback; + webpackFactory?: WebpackFactory; + shouldProvideStats?: boolean; +}): Observable; + +// @public @deprecated (undocumented) +export function runWebpackDevServer(config: webpack.Configuration, context: BuilderContext, options?: { + shouldProvideStats?: boolean; + devServerConfig?: Configuration; + logging?: WebpackLoggingCallback; + webpackFactory?: WebpackFactory; + webpackDevServerFactory?: WebpackDevServerFactory; +}): Observable; + +// @public (undocumented) +export type WebpackBuilderSchema = Schema; + +// @public (undocumented) +export type WebpackDevServerFactory = typeof WebpackDevServer; + +// @public (undocumented) +export interface WebpackFactory { + // (undocumented) + (config: webpack.Configuration): Observable | webpack.Compiler | null; +} + +// @public (undocumented) +export interface WebpackLoggingCallback { + // (undocumented) + (stats: webpack.Stats, config: webpack.Configuration): void; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/core/index.api.md b/goldens/public-api/angular_devkit/core/index.api.md new file mode 100644 index 000000000000..d86a826c566f --- /dev/null +++ b/goldens/public-api/angular_devkit/core/index.api.md @@ -0,0 +1,1400 @@ +## API Report File for "@angular-devkit/core" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ErrorObject } from 'ajv'; +import { Format } from 'ajv'; +import { Observable } from 'rxjs'; +import { ObservableInput } from 'rxjs'; +import { Operator } from 'rxjs'; +import { PartialObserver } from 'rxjs'; +import { Position } from 'source-map'; +import { Subject } from 'rxjs'; +import { Subscription } from 'rxjs'; +import { ValidateFunction } from 'ajv'; + +// @public (undocumented) +function addUndefinedDefaults(value: JsonValue, _pointer: JsonPointer, schema?: JsonSchema): JsonValue; + +// @public (undocumented) +function addUndefinedObjectDefaults(value: JsonValue, _pointer: JsonPointer, schema?: JsonSchema): JsonValue; + +// @public +class AliasHost extends ResolverHost { + // (undocumented) + get aliases(): Map; + // (undocumented) + protected _aliases: Map; + // (undocumented) + protected _resolve(path: Path): Path; +} + +// @public (undocumented) +export function asPosixPath(path: Path): PosixPath; + +// @public (undocumented) +export function asWindowsPath(path: Path): WindowsPath; + +// @public +export class BaseException extends Error { + constructor(message?: string); +} + +// @public +export function basename(path: Path): PathFragment; + +// @public (undocumented) +function buildJsonPointer(fragments: string[]): JsonPointer; + +// @public +function camelize(str: string): string; + +// @public +function capitalize(str: string): string; + +// @public (undocumented) +export class CircularDependencyFoundException extends BaseException { + constructor(); +} + +// @public +function classify(str: string): string; + +// @public +class CordHost extends SimpleMemoryHost { + constructor(_back: ReadonlyHost); + // (undocumented) + protected _back: ReadonlyHost; + // (undocumented) + get backend(): ReadonlyHost; + // (undocumented) + get capabilities(): HostCapabilities; + clone(): CordHost; + commit(host: Host, force?: boolean): Observable; + create(path: Path, content: FileBuffer): Observable; + // (undocumented) + delete(path: Path): Observable; + // (undocumented) + exists(path: Path): Observable; + // (undocumented) + protected _filesToCreate: Set; + // (undocumented) + protected _filesToDelete: Set; + // (undocumented) + protected _filesToOverwrite: Set; + // (undocumented) + protected _filesToRename: Map; + // (undocumented) + protected _filesToRenameRevert: Map; + // (undocumented) + isDirectory(path: Path): Observable; + // (undocumented) + isFile(path: Path): Observable; + // (undocumented) + list(path: Path): Observable; + // (undocumented) + overwrite(path: Path, content: FileBuffer): Observable; + // (undocumented) + read(path: Path): Observable; + // (undocumented) + records(): CordHostRecord[]; + // (undocumented) + rename(from: Path, to: Path): Observable; + // (undocumented) + stat(path: Path): Observable | null; + // (undocumented) + watch(path: Path, options?: HostWatchOptions): null; + // (undocumented) + willCreate(path: Path): boolean; + // (undocumented) + willDelete(path: Path): boolean; + // (undocumented) + willOverwrite(path: Path): boolean; + // (undocumented) + willRename(path: Path): boolean; + // (undocumented) + willRenameTo(path: Path, to: Path): boolean; + // (undocumented) + write(path: Path, content: FileBuffer): Observable; +} + +// @public (undocumented) +interface CordHostCreate { + // (undocumented) + content: FileBuffer; + // (undocumented) + kind: 'create'; + // (undocumented) + path: Path; +} + +// @public (undocumented) +interface CordHostDelete { + // (undocumented) + kind: 'delete'; + // (undocumented) + path: Path; +} + +// @public (undocumented) +interface CordHostOverwrite { + // (undocumented) + content: FileBuffer; + // (undocumented) + kind: 'overwrite'; + // (undocumented) + path: Path; +} + +// @public (undocumented) +type CordHostRecord = CordHostCreate | CordHostOverwrite | CordHostRename | CordHostDelete; + +// @public (undocumented) +interface CordHostRename { + // (undocumented) + from: Path; + // (undocumented) + kind: 'rename'; + // (undocumented) + to: Path; +} + +// @public (undocumented) +class CoreSchemaRegistry implements SchemaRegistry { + constructor(formats?: SchemaFormat[]); + // (undocumented) + addFormat(format: SchemaFormat): void; + addPostTransform(visitor: JsonVisitor, deps?: JsonVisitor[]): void; + addPreTransform(visitor: JsonVisitor, deps?: JsonVisitor[]): void; + // (undocumented) + addSmartDefaultProvider(source: string, provider: SmartDefaultProvider): void; + compile(schema: JsonSchema): Promise; + // (undocumented) + registerUriHandler(handler: UriHandler): void; + // (undocumented) + protected _resolver(ref: string, validate?: ValidateFunction): { + context?: ValidateFunction; + schema?: JsonObject; + }; + // (undocumented) + usePromptProvider(provider: PromptProvider): void; + // (undocumented) + useXDeprecatedProvider(onUsage: (message: string) => void): void; + ɵflatten(schema: JsonObject): Promise; +} + +// @public (undocumented) +function createSyncHost(handler: SyncHostHandler): Host; + +// @public (undocumented) +function createWorkspaceHost(host: virtualFs.Host): WorkspaceHost; + +// @public +function dasherize(str: string): string; + +// @public +function decamelize(str: string): string; + +// @public @deprecated (undocumented) +export function deepCopy(value: T): T; + +// @public (undocumented) +type DefinitionCollectionListener = (name: string, newValue: V | undefined, collection: DefinitionCollection) => void; + +// @public (undocumented) +export class DependencyNotFoundException extends BaseException { + constructor(); +} + +// @public +export function dirname(path: Path): Path; + +// @public (undocumented) +class Empty implements ReadonlyHost { + // (undocumented) + readonly capabilities: HostCapabilities; + // (undocumented) + exists(path: Path): Observable; + // (undocumented) + isDirectory(path: Path): Observable; + // (undocumented) + isFile(path: Path): Observable; + // (undocumented) + list(path: Path): Observable; + // (undocumented) + read(path: Path): Observable; + // (undocumented) + stat(path: Path): Observable | null>; +} + +// @public (undocumented) +export function extname(path: Path): string; + +// @public (undocumented) +export class FileAlreadyExistException extends BaseException { + constructor(path: string); +} + +// @public (undocumented) +type FileBuffer = ArrayBuffer; + +// @public (undocumented) +type FileBufferLike = ArrayBufferLike; + +// @public @deprecated (undocumented) +function fileBufferToString(fileBuffer: FileBuffer): string; + +// @public (undocumented) +export class FileDoesNotExistException extends BaseException { + constructor(path: string); +} + +// @public (undocumented) +export function fragment(path: string): PathFragment; + +// @public (undocumented) +export function getSystemPath(path: Path): string; + +// @public (undocumented) +function getTypesOfSchema(schema: JsonSchema): Set; + +// @public (undocumented) +interface Host extends ReadonlyHost { + // (undocumented) + delete(path: Path): Observable; + // (undocumented) + rename(from: Path, to: Path): Observable; + // (undocumented) + watch(path: Path, options?: HostWatchOptions): Observable | null; + // (undocumented) + write(path: Path, content: FileBufferLike): Observable; +} + +// @public (undocumented) +interface HostCapabilities { + // (undocumented) + synchronous: boolean; +} + +// @public (undocumented) +interface HostWatchEvent { + // (undocumented) + readonly path: Path; + // (undocumented) + readonly time: Date; + // (undocumented) + readonly type: HostWatchEventType; +} + +// @public (undocumented) +enum HostWatchEventType { + // (undocumented) + Changed = 0, + // (undocumented) + Created = 1, + // (undocumented) + Deleted = 2, + // (undocumented) + Renamed = 3 +} + +// @public (undocumented) +interface HostWatchOptions { + // (undocumented) + readonly persistent?: boolean; + // (undocumented) + readonly recursive?: boolean; +} + +// @public (undocumented) +function indentBy(indentations: number): TemplateTag; + +// @public (undocumented) +class IndentLogger extends Logger { + constructor(name: string, parent?: Logger | null, indentation?: string); +} + +// @public (undocumented) +export class InvalidPathException extends BaseException { + constructor(path: string); +} + +// @public +export function isAbsolute(p: Path): boolean; + +// @public (undocumented) +export function isJsonArray(value: JsonValue): value is JsonArray; + +// @public (undocumented) +export function isJsonObject(value: JsonValue): value is JsonObject; + +// @public (undocumented) +function isJsonSchema(value: unknown): value is JsonSchema; + +// @public @deprecated +export function isPromise(obj: any): obj is Promise; + +// @public +export function join(p1: Path, ...others: string[]): Path; + +// @public (undocumented) +function joinJsonPointer(root: JsonPointer, ...others: string[]): JsonPointer; + +declare namespace json { + export { + schema, + isJsonObject, + isJsonArray, + JsonArray, + JsonObject, + JsonValue + } +} +export { json } + +// @public +export interface JsonArray extends Array { +} + +// @public (undocumented) +export interface JsonObject { + // (undocumented) + [prop: string]: JsonValue; +} + +// @public (undocumented) +type JsonPointer = string & { + __PRIVATE_DEVKIT_JSON_POINTER: void; +}; + +// @public +type JsonSchema = JsonObject | boolean; + +// @public (undocumented) +interface JsonSchemaVisitor { + // (undocumented) + (current: JsonObject | JsonArray, pointer: JsonPointer, parentSchema?: JsonObject | JsonArray, index?: string): void; +} + +// @public (undocumented) +export type JsonValue = boolean | string | number | JsonArray | JsonObject | null; + +// @public (undocumented) +interface JsonVisitor { + // (undocumented) + (value: JsonValue, pointer: JsonPointer, schema?: JsonObject, root?: JsonObject | JsonArray): Observable | JsonValue; +} + +// @public (undocumented) +class LevelCapLogger extends LevelTransformLogger { + constructor(name: string, parent: Logger | null, levelCap: LogLevel); + // (undocumented) + readonly levelCap: LogLevel; + // (undocumented) + static levelMap: { + [cap: string]: { + [level: string]: string; + }; + }; + // (undocumented) + readonly name: string; + // (undocumented) + readonly parent: Logger | null; +} + +// @public (undocumented) +class LevelTransformLogger extends Logger { + constructor(name: string, parent: Logger | null, levelTransform: (level: LogLevel) => LogLevel); + // (undocumented) + createChild(name: string): Logger; + // (undocumented) + readonly levelTransform: (level: LogLevel) => LogLevel; + // (undocumented) + log(level: LogLevel, message: string, metadata?: JsonObject): void; + // (undocumented) + readonly name: string; + // (undocumented) + readonly parent: Logger | null; +} + +// @public +function levenshtein(a: string, b: string): number; + +// @public (undocumented) +interface LogEntry extends LoggerMetadata { + // (undocumented) + level: LogLevel; + // (undocumented) + message: string; + // (undocumented) + timestamp: number; +} + +// @public (undocumented) +class Logger extends Observable implements LoggerApi { + constructor(name: string, parent?: Logger | null); + // (undocumented) + asApi(): LoggerApi; + // (undocumented) + complete(): void; + // (undocumented) + createChild(name: string): Logger; + // (undocumented) + debug(message: string, metadata?: JsonObject): void; + // (undocumented) + error(message: string, metadata?: JsonObject): void; + // (undocumented) + fatal(message: string, metadata?: JsonObject): void; + // (undocumented) + forEach(next: (value: LogEntry) => void, promiseCtor?: PromiseConstructorLike): Promise; + // (undocumented) + info(message: string, metadata?: JsonObject): void; + // (undocumented) + lift(operator: Operator): Observable; + // (undocumented) + log(level: LogLevel, message: string, metadata?: JsonObject): void; + // (undocumented) + protected _metadata: LoggerMetadata; + // (undocumented) + readonly name: string; + // (undocumented) + next(entry: LogEntry): void; + // (undocumented) + protected get _observable(): Observable; + protected set _observable(v: Observable); + // (undocumented) + readonly parent: Logger | null; + // (undocumented) + protected readonly _subject: Subject; + // (undocumented) + subscribe(): Subscription; + // (undocumented) + subscribe(observer: PartialObserver): Subscription; + // (undocumented) + subscribe(next?: (value: LogEntry) => void, error?: (error: Error) => void, complete?: () => void): Subscription; + // (undocumented) + toString(): string; + // (undocumented) + warn(message: string, metadata?: JsonObject): void; +} + +// @public (undocumented) +interface LoggerApi { + // (undocumented) + createChild(name: string): Logger; + // (undocumented) + debug(message: string, metadata?: JsonObject): void; + // (undocumented) + error(message: string, metadata?: JsonObject): void; + // (undocumented) + fatal(message: string, metadata?: JsonObject): void; + // (undocumented) + info(message: string, metadata?: JsonObject): void; + // (undocumented) + log(level: LogLevel, message: string, metadata?: JsonObject): void; + // (undocumented) + warn(message: string, metadata?: JsonObject): void; +} + +// @public (undocumented) +interface LoggerMetadata extends JsonObject { + // (undocumented) + name: string; + // (undocumented) + path: string[]; +} + +declare namespace logging { + export { + IndentLogger, + LevelTransformLogger, + LevelCapLogger, + LoggerMetadata, + LogEntry, + LoggerApi, + LogLevel, + Logger, + NullLogger, + TransformLogger + } +} +export { logging } + +// @public (undocumented) +type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; + +// @public +function mergeSchemas(...schemas: (JsonSchema | undefined)[]): JsonSchema; + +// @public +export function noCacheNormalize(path: string): Path; + +// @public +export function normalize(path: string): Path; + +// @public +export const NormalizedRoot: Path; + +// @public +export const NormalizedSep: Path; + +// @public (undocumented) +class NullLogger extends Logger { + constructor(parent?: Logger | null); + // (undocumented) + asApi(): LoggerApi; +} + +// @public (undocumented) +function oneLine(strings: TemplateStringsArray, ...values: any[]): string; + +// @public (undocumented) +function parseJsonPointer(pointer: JsonPointer): string[]; + +// @public @deprecated (undocumented) +export class PartiallyOrderedSet { + // (undocumented) + [Symbol.iterator](): IterableIterator; + // (undocumented) + get [Symbol.toStringTag](): 'PartiallyOrderedSet'; + // (undocumented) + add(item: T, deps?: Set | T[]): this; + // (undocumented) + protected _checkCircularDependencies(item: T, deps: Set): void; + // (undocumented) + clear(): void; + // (undocumented) + delete(item: T): boolean; + entries(): IterableIterator<[T, T]>; + // (undocumented) + forEach(callbackfn: (value: T, value2: T, set: PartiallyOrderedSet) => void, thisArg?: any): void; + // (undocumented) + has(item: T): boolean; + keys(): IterableIterator; + // (undocumented) + get size(): number; + values(): IterableIterator; +} + +// @public +export type Path = string & { + __PRIVATE_DEVKIT_PATH: void; +}; + +// @public (undocumented) +export const path: TemplateTag; + +// @public (undocumented) +export class PathCannotBeFragmentException extends BaseException { + constructor(path: string); +} + +// @public +export type PathFragment = Path & { + __PRIVATE_DEVKIT_PATH_FRAGMENT: void; +}; + +// @public (undocumented) +export class PathIsDirectoryException extends BaseException { + constructor(path: string); +} + +// @public (undocumented) +export class PathIsFileException extends BaseException { + constructor(path: string); +} + +// @public (undocumented) +export class PathMustBeAbsoluteException extends BaseException { + constructor(path: string); +} + +// @public (undocumented) +class PatternMatchingHost extends ResolverHost { + // (undocumented) + addPattern(pattern: string | string[], replacementFn: ReplacementFunction): void; + // (undocumented) + protected _patterns: Map; + // (undocumented) + protected _resolve(path: Path): Path; +} + +// @public (undocumented) +export type PosixPath = string & { + __PRIVATE_DEVKIT_POSIX_PATH: void; +}; + +// @public +export class PriorityQueue { + constructor(_comparator: (x: T, y: T) => number); + // (undocumented) + clear(): void; + // (undocumented) + peek(): T | undefined; + // (undocumented) + pop(): T | undefined; + // (undocumented) + push(item: T): void; + // (undocumented) + get size(): number; + // (undocumented) + toArray(): Array; +} + +// @public (undocumented) +interface ProjectDefinition { + // (undocumented) + readonly extensions: Record; + // (undocumented) + prefix?: string; + // (undocumented) + root: string; + // (undocumented) + sourceRoot?: string; + // (undocumented) + readonly targets: TargetDefinitionCollection; +} + +// @public (undocumented) +class ProjectDefinitionCollection extends DefinitionCollection { + constructor(initial?: Record, listener?: DefinitionCollectionListener); + // (undocumented) + add(definition: { + name: string; + root: string; + sourceRoot?: string; + prefix?: string; + targets?: Record; + [key: string]: unknown; + }): ProjectDefinition; + // (undocumented) + set(name: string, value: ProjectDefinition): this; +} + +// @public (undocumented) +interface PromptDefinition { + // (undocumented) + default?: string | string[] | number | boolean | null; + // (undocumented) + id: string; + // (undocumented) + items?: Array; + // (undocumented) + message: string; + // (undocumented) + multiselect?: boolean; + // (undocumented) + propertyTypes: Set; + // (undocumented) + raw?: string | JsonObject; + // (undocumented) + type: string; + // (undocumented) + validator?: (value: JsonValue) => boolean | string | Promise; +} + +// @public (undocumented) +type PromptProvider = (definitions: Array) => ObservableInput<{ + [id: string]: JsonValue; +}>; + +// @public (undocumented) +interface ReadonlyHost { + // (undocumented) + readonly capabilities: HostCapabilities; + // (undocumented) + exists(path: Path): Observable; + // (undocumented) + isDirectory(path: Path): Observable; + // (undocumented) + isFile(path: Path): Observable; + // (undocumented) + list(path: Path): Observable; + // (undocumented) + read(path: Path): Observable; + // (undocumented) + stat(path: Path): Observable | null> | null; +} + +// @public +function readWorkspace(path: string, host: WorkspaceHost, format?: WorkspaceFormat): Promise<{ + workspace: WorkspaceDefinition; +}>; + +// @public (undocumented) +interface ReferenceResolver { + // (undocumented) + (ref: string, context?: ContextT): { + context?: ContextT; + schema?: JsonObject; + }; +} + +// @public +export function relative(from: Path, to: Path): Path; + +// @public (undocumented) +type ReplacementFunction = (path: Path) => Path; + +// @public +export function resetNormalizeCache(): void; + +// @public +export function resolve(p1: Path, p2: Path): Path; + +// @public +abstract class ResolverHost implements Host { + constructor(_delegate: Host); + // (undocumented) + get capabilities(): HostCapabilities; + // (undocumented) + protected _delegate: Host; + // (undocumented) + delete(path: Path): Observable; + // (undocumented) + exists(path: Path): Observable; + // (undocumented) + isDirectory(path: Path): Observable; + // (undocumented) + isFile(path: Path): Observable; + // (undocumented) + list(path: Path): Observable; + // (undocumented) + read(path: Path): Observable; + // (undocumented) + rename(from: Path, to: Path): Observable; + // (undocumented) + protected abstract _resolve(path: Path): Path; + // (undocumented) + stat(path: Path): Observable | null> | null; + // (undocumented) + watch(path: Path, options?: HostWatchOptions): Observable | null; + // (undocumented) + write(path: Path, content: FileBuffer): Observable; +} + +// @public +class SafeReadonlyHost implements ReadonlyHost { + constructor(_delegate: ReadonlyHost); + // (undocumented) + get capabilities(): HostCapabilities; + // (undocumented) + exists(path: Path): Observable; + // (undocumented) + isDirectory(path: Path): Observable; + // (undocumented) + isFile(path: Path): Observable; + // (undocumented) + list(path: Path): Observable; + // (undocumented) + read(path: Path): Observable; + // (undocumented) + stat(path: Path): Observable | null> | null; +} + +declare namespace schema { + export { + transforms, + JsonPointer, + SchemaValidatorResult, + SchemaValidatorError, + SchemaValidatorOptions, + SchemaValidator, + SchemaFormatter, + SchemaFormat, + SmartDefaultProvider, + SchemaKeywordValidator, + PromptDefinition, + PromptProvider, + SchemaRegistry, + JsonSchemaVisitor, + JsonVisitor, + buildJsonPointer, + joinJsonPointer, + parseJsonPointer, + UriHandler, + SchemaValidationException, + CoreSchemaRegistry, + isJsonSchema, + mergeSchemas, + JsonSchema, + visitJson, + visitJsonSchema, + ReferenceResolver, + getTypesOfSchema + } +} +export { schema } + +// @public (undocumented) +interface SchemaFormat { + // (undocumented) + formatter: SchemaFormatter; + // (undocumented) + name: string; +} + +// @public (undocumented) +type SchemaFormatter = Format; + +// @public (undocumented) +interface SchemaKeywordValidator { + // (undocumented) + (data: JsonValue, schema: JsonValue, parent: JsonObject | JsonArray | undefined, parentProperty: string | number | undefined, pointer: JsonPointer, rootData: JsonValue): boolean | Observable; +} + +// @public (undocumented) +interface SchemaRegistry { + // (undocumented) + addFormat(format: SchemaFormat): void; + addPostTransform(visitor: JsonVisitor, deps?: JsonVisitor[]): void; + addPreTransform(visitor: JsonVisitor, deps?: JsonVisitor[]): void; + // (undocumented) + addSmartDefaultProvider(source: string, provider: SmartDefaultProvider): void; + // (undocumented) + compile(schema: Object): Promise; + // (undocumented) + usePromptProvider(provider: PromptProvider): void; + // (undocumented) + useXDeprecatedProvider(onUsage: (message: string) => void): void; + // (undocumented) + ɵflatten(schema: JsonObject | string): Promise; +} + +// @public (undocumented) +class SchemaValidationException extends BaseException { + constructor(errors?: SchemaValidatorError[], baseMessage?: string); + // (undocumented) + static createMessages(errors?: SchemaValidatorError[]): string[]; + // (undocumented) + readonly errors: SchemaValidatorError[]; +} + +// @public (undocumented) +interface SchemaValidator { + // (undocumented) + (data: JsonValue, options?: SchemaValidatorOptions): Promise; +} + +// @public (undocumented) +type SchemaValidatorError = Partial; + +// @public (undocumented) +interface SchemaValidatorOptions { + // (undocumented) + applyPostTransforms?: boolean; + // (undocumented) + applyPreTransforms?: boolean; + // (undocumented) + withPrompts?: boolean; +} + +// @public (undocumented) +interface SchemaValidatorResult { + // (undocumented) + data: JsonValue; + // (undocumented) + errors?: SchemaValidatorError[]; + // (undocumented) + success: boolean; +} + +// @public (undocumented) +class ScopedHost extends ResolverHost { + constructor(delegate: Host, _root?: Path); + // (undocumented) + protected _resolve(path: Path): Path; + // (undocumented) + protected _root: Path; +} + +// @public (undocumented) +class SimpleMemoryHost implements Host<{}> { + constructor(); + // (undocumented) + protected _cache: Map>; + // (undocumented) + get capabilities(): HostCapabilities; + // (undocumented) + delete(path: Path): Observable; + // (undocumented) + protected _delete(path: Path): void; + // (undocumented) + exists(path: Path): Observable; + // (undocumented) + protected _exists(path: Path): boolean; + // (undocumented) + isDirectory(path: Path): Observable; + // (undocumented) + protected _isDirectory(path: Path): boolean; + // (undocumented) + isFile(path: Path): Observable; + // (undocumented) + protected _isFile(path: Path): boolean; + // (undocumented) + list(path: Path): Observable; + // (undocumented) + protected _list(path: Path): PathFragment[]; + // (undocumented) + protected _newDirStats(): Stats; + // (undocumented) + protected _newFileStats(content: FileBuffer, oldStats?: Stats): Stats; + // (undocumented) + read(path: Path): Observable; + // (undocumented) + protected _read(path: Path): FileBuffer; + // (undocumented) + rename(from: Path, to: Path): Observable; + // (undocumented) + protected _rename(from: Path, to: Path): void; + // (undocumented) + reset(): void; + // (undocumented) + stat(path: Path): Observable | null> | null; + // (undocumented) + protected _stat(path: Path): Stats | null; + // (undocumented) + protected _toAbsolute(path: Path): Path; + // (undocumented) + protected _updateWatchers(path: Path, type: HostWatchEventType): void; + // (undocumented) + watch(path: Path, options?: HostWatchOptions): Observable | null; + // (undocumented) + protected _watch(path: Path, options?: HostWatchOptions): Observable; + // (undocumented) + write(path: Path, content: FileBuffer): Observable; + protected _write(path: Path, content: FileBuffer): void; +} + +// @public (undocumented) +interface SimpleMemoryHostStats { + // (undocumented) + readonly content: FileBuffer | null; + // (undocumented) + inspect(): string; +} + +// @public (undocumented) +interface SmartDefaultProvider { + // (undocumented) + (schema: JsonObject): T | Observable; +} + +// @public +export function split(path: Path): PathFragment[]; + +// @public (undocumented) +type Stats = T & { + isFile(): boolean; + isDirectory(): boolean; + readonly size: number; + readonly atime: Date; + readonly mtime: Date; + readonly ctime: Date; + readonly birthtime: Date; +}; + +declare namespace strings { + export { + decamelize, + dasherize, + camelize, + classify, + underscore, + capitalize, + levenshtein + } +} +export { strings } + +// @public @deprecated (undocumented) +function stringToFileBuffer(str: string): FileBuffer; + +// @public (undocumented) +function stripIndent(strings: TemplateStringsArray, ...values: any[]): string; + +// @public (undocumented) +function stripIndents(strings: TemplateStringsArray, ...values: any[]): string; + +// @public +class SyncDelegateHost { + constructor(_delegate: Host); + // (undocumented) + get capabilities(): HostCapabilities; + // (undocumented) + get delegate(): Host; + // (undocumented) + protected _delegate: Host; + // (undocumented) + delete(path: Path): void; + // (undocumented) + protected _doSyncCall(observable: Observable): ResultT; + // (undocumented) + exists(path: Path): boolean; + // (undocumented) + isDirectory(path: Path): boolean; + // (undocumented) + isFile(path: Path): boolean; + // (undocumented) + list(path: Path): PathFragment[]; + // (undocumented) + read(path: Path): FileBuffer; + // (undocumented) + rename(from: Path, to: Path): void; + // (undocumented) + stat(path: Path): Stats | null; + // (undocumented) + watch(path: Path, options?: HostWatchOptions): Observable | null; + // (undocumented) + write(path: Path, content: FileBufferLike): void; +} + +// @public (undocumented) +interface SyncHostHandler { + // (undocumented) + delete(path: Path): void; + // (undocumented) + exists(path: Path): boolean; + // (undocumented) + isDirectory(path: Path): boolean; + // (undocumented) + isFile(path: Path): boolean; + // (undocumented) + list(path: Path): PathFragment[]; + // (undocumented) + read(path: Path): FileBuffer; + // (undocumented) + rename(from: Path, to: Path): void; + // (undocumented) + stat(path: Path): Stats | null; + // (undocumented) + write(path: Path, content: FileBufferLike): void; +} + +// @public (undocumented) +class SynchronousDelegateExpectedException extends BaseException { + constructor(); +} + +declare namespace tags { + export { + oneLine, + indentBy, + stripIndent, + stripIndents, + trimNewlines, + TemplateTag + } +} +export { tags } + +// @public (undocumented) +interface TargetDefinition { + // (undocumented) + builder: string; + // (undocumented) + configurations?: Record | undefined>; + // (undocumented) + defaultConfiguration?: string; + // (undocumented) + options?: Record; +} + +// @public (undocumented) +class TargetDefinitionCollection extends DefinitionCollection { + constructor(initial?: Record, listener?: DefinitionCollectionListener); + // (undocumented) + add(definition: { + name: string; + } & TargetDefinition): TargetDefinition; + // (undocumented) + set(name: string, value: TargetDefinition): this; +} + +// @public +export function template(content: string, options?: TemplateOptions): (input: T) => string; + +// @public +export interface TemplateAst { + // (undocumented) + children: TemplateAstNode[]; + // (undocumented) + content: string; + // (undocumented) + fileName: string; +} + +// @public +export interface TemplateAstBase { + // (undocumented) + end: Position; + // (undocumented) + start: Position; +} + +// @public +export interface TemplateAstComment extends TemplateAstBase { + // (undocumented) + kind: 'comment'; + // (undocumented) + text: string; +} + +// @public +export interface TemplateAstContent extends TemplateAstBase { + // (undocumented) + content: string; + // (undocumented) + kind: 'content'; +} + +// @public +export interface TemplateAstEscape extends TemplateAstBase { + // (undocumented) + expression: string; + // (undocumented) + kind: 'escape'; +} + +// @public +export interface TemplateAstEvaluate extends TemplateAstBase { + // (undocumented) + expression: string; + // (undocumented) + kind: 'evaluate'; +} + +// @public +export interface TemplateAstInterpolate extends TemplateAstBase { + // (undocumented) + expression: string; + // (undocumented) + kind: 'interpolate'; +} + +// @public (undocumented) +export type TemplateAstNode = TemplateAstContent | TemplateAstEvaluate | TemplateAstComment | TemplateAstEscape | TemplateAstInterpolate; + +// @public (undocumented) +export interface TemplateOptions { + // (undocumented) + fileName?: string; + // (undocumented) + module?: boolean | { + exports: {}; + }; + // (undocumented) + sourceMap?: boolean; + // (undocumented) + sourceRoot?: string; + // (undocumented) + sourceURL?: string; +} + +// @public +export function templateParser(sourceText: string, fileName: string): TemplateAst; + +// @public +interface TemplateTag { + // (undocumented) + (template: TemplateStringsArray, ...substitutions: any[]): R; +} + +declare namespace test { + export { + TestLogRecord, + TestHost + } +} + +// @public (undocumented) +class TestHost extends SimpleMemoryHost { + // (undocumented) + $exists(path: string): boolean; + // (undocumented) + $isDirectory(path: string): boolean; + // (undocumented) + $isFile(path: string): boolean; + // (undocumented) + $list(path: string): PathFragment[]; + // (undocumented) + $read(path: string): string; + // (undocumented) + $write(path: string, content: string): void; + constructor(map?: { + [path: string]: string; + }); + // (undocumented) + clearRecords(): void; + // (undocumented) + clone(): TestHost; + // (undocumented) + protected _delete(path: Path): void; + // (undocumented) + protected _exists(path: Path): boolean; + // (undocumented) + get files(): Path[]; + // (undocumented) + protected _isDirectory(path: Path): boolean; + // (undocumented) + protected _isFile(path: Path): boolean; + // (undocumented) + protected _list(path: Path): PathFragment[]; + // (undocumented) + protected _read(path: Path): ArrayBuffer; + // (undocumented) + get records(): TestLogRecord[]; + // (undocumented) + protected _records: TestLogRecord[]; + // (undocumented) + protected _rename(from: Path, to: Path): void; + // (undocumented) + protected _stat(path: Path): Stats | null; + // (undocumented) + get sync(): SyncDelegateHost<{}>; + // (undocumented) + protected _sync: SyncDelegateHost<{}> | null; + // (undocumented) + protected _watch(path: Path, options?: HostWatchOptions): Observable; + // (undocumented) + protected _write(path: Path, content: FileBuffer): void; +} + +// @public (undocumented) +type TestLogRecord = { + kind: 'write' | 'read' | 'delete' | 'list' | 'exists' | 'isDirectory' | 'isFile' | 'stat' | 'watch'; + path: Path; +} | { + kind: 'rename'; + from: Path; + to: Path; +}; + +// @public (undocumented) +class TransformLogger extends Logger { + constructor(name: string, transform: (stream: Observable) => Observable, parent?: Logger | null); +} + +declare namespace transforms { + export { + addUndefinedObjectDefaults, + addUndefinedDefaults + } +} + +// @public (undocumented) +function trimNewlines(strings: TemplateStringsArray, ...values: any[]): string; + +// @public +function underscore(str: string): string; + +// @public (undocumented) +export class UnknownException extends BaseException { + constructor(message: string); +} + +// @public (undocumented) +type UriHandler = (uri: string) => Observable | Promise | null | undefined; + +declare namespace virtualFs { + export { + test, + AliasHost, + stringToFileBuffer, + fileBufferToString, + createSyncHost, + SyncHostHandler, + Empty, + FileBuffer, + FileBufferLike, + HostWatchOptions, + HostWatchEventType, + Stats, + HostWatchEvent, + HostCapabilities, + ReadonlyHost, + Host, + SimpleMemoryHostStats, + SimpleMemoryHost, + ReplacementFunction, + PatternMatchingHost, + CordHostCreate, + CordHostOverwrite, + CordHostRename, + CordHostDelete, + CordHostRecord, + CordHost, + SafeReadonlyHost, + ScopedHost, + SynchronousDelegateExpectedException, + SyncDelegateHost, + ResolverHost + } +} +export { virtualFs } + +// @public +function visitJson(json: JsonValue, visitor: JsonVisitor, schema?: JsonSchema, refResolver?: ReferenceResolver, context?: ContextT): Observable; + +// @public (undocumented) +function visitJsonSchema(schema: JsonSchema, visitor: JsonSchemaVisitor): void; + +// @public (undocumented) +export type WindowsPath = string & { + __PRIVATE_DEVKIT_WINDOWS_PATH: void; +}; + +// @public (undocumented) +interface WorkspaceDefinition { + // (undocumented) + readonly extensions: Record; + // (undocumented) + readonly projects: ProjectDefinitionCollection; +} + +// @public +enum WorkspaceFormat { + // (undocumented) + JSON = 0 +} + +// @public (undocumented) +interface WorkspaceHost { + // (undocumented) + isDirectory(path: string): Promise; + // (undocumented) + isFile(path: string): Promise; + // (undocumented) + readFile(path: string): Promise; + // (undocumented) + writeFile(path: string, data: string): Promise; +} + +declare namespace workspaces { + export { + WorkspaceHost, + createWorkspaceHost, + WorkspaceFormat, + readWorkspace, + writeWorkspace, + WorkspaceDefinition, + ProjectDefinition, + TargetDefinition, + DefinitionCollectionListener, + ProjectDefinitionCollection, + TargetDefinitionCollection + } +} +export { workspaces } + +// @public +function writeWorkspace(workspace: WorkspaceDefinition, host: WorkspaceHost, path?: string, format?: WorkspaceFormat): Promise; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/core/node/index.api.md b/goldens/public-api/angular_devkit/core/node/index.api.md new file mode 100644 index 000000000000..9fcdee9770cb --- /dev/null +++ b/goldens/public-api/angular_devkit/core/node/index.api.md @@ -0,0 +1,77 @@ +## API Report File for "@angular-devkit/core_node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Observable } from 'rxjs'; +import { Operator } from 'rxjs'; +import { PartialObserver } from 'rxjs'; +import { Stats as Stats_2 } from 'node:fs'; +import { Subject } from 'rxjs'; +import { Subscription } from 'rxjs'; + +// @public @deprecated +export function createConsoleLogger(verbose?: boolean, stdout?: ProcessOutput, stderr?: ProcessOutput, colors?: Partial string>>): logging.Logger; + +// @public +export class NodeJsAsyncHost implements virtualFs.Host { + // (undocumented) + get capabilities(): virtualFs.HostCapabilities; + // (undocumented) + delete(path: Path): Observable; + // (undocumented) + exists(path: Path): Observable; + // (undocumented) + isDirectory(path: Path): Observable; + // (undocumented) + isFile(path: Path): Observable; + // (undocumented) + list(path: Path): Observable; + // (undocumented) + read(path: Path): Observable; + // (undocumented) + rename(from: Path, to: Path): Observable; + // (undocumented) + stat(path: Path): Observable>; + // (undocumented) + watch(path: Path, _options?: virtualFs.HostWatchOptions): Observable | null; + // (undocumented) + write(path: Path, content: virtualFs.FileBuffer): Observable; +} + +// @public +export class NodeJsSyncHost implements virtualFs.Host { + // (undocumented) + get capabilities(): virtualFs.HostCapabilities; + // (undocumented) + delete(path: Path): Observable; + // (undocumented) + exists(path: Path): Observable; + // (undocumented) + isDirectory(path: Path): Observable; + // (undocumented) + isFile(path: Path): Observable; + // (undocumented) + list(path: Path): Observable; + // (undocumented) + read(path: Path): Observable; + // (undocumented) + rename(from: Path, to: Path): Observable; + // (undocumented) + stat(path: Path): Observable>; + // (undocumented) + watch(path: Path, _options?: virtualFs.HostWatchOptions): Observable | null; + // (undocumented) + write(path: Path, content: virtualFs.FileBuffer): Observable; +} + +// @public (undocumented) +export interface ProcessOutput { + // (undocumented) + write(buffer: string | Buffer): boolean; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/core/node/testing/index.api.md b/goldens/public-api/angular_devkit/core/node/testing/index.api.md new file mode 100644 index 000000000000..116019d266a1 --- /dev/null +++ b/goldens/public-api/angular_devkit/core/node/testing/index.api.md @@ -0,0 +1,27 @@ +## API Report File for "@angular-devkit/core_node_testing" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import * as fs from 'node:fs'; +import { Observable } from 'rxjs'; + +// @public +export class TempScopedNodeJsSyncHost extends virtualFs.ScopedHost { + constructor(); + // (undocumented) + get files(): Path[]; + // (undocumented) + get root(): Path; + // (undocumented) + protected _root: Path; + // (undocumented) + get sync(): virtualFs.SyncDelegateHost; + // (undocumented) + protected _sync?: virtualFs.SyncDelegateHost; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/schematics/index.api.md b/goldens/public-api/angular_devkit/schematics/index.api.md new file mode 100644 index 000000000000..efb5da7c8166 --- /dev/null +++ b/goldens/public-api/angular_devkit/schematics/index.api.md @@ -0,0 +1,1065 @@ +## API Report File for "@angular-devkit/schematics" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BaseException } from '@angular-devkit/core'; +import { JsonValue } from '@angular-devkit/core'; +import { logging } from '@angular-devkit/core'; +import { Observable } from 'rxjs'; +import { Path } from '@angular-devkit/core'; +import { PathFragment } from '@angular-devkit/core'; +import { schema } from '@angular-devkit/core'; +import { strings } from '@angular-devkit/core'; +import { Subject } from 'rxjs'; +import { Url } from 'node:url'; +import { virtualFs } from '@angular-devkit/core'; + +// @public (undocumented) +export type Action = CreateFileAction | OverwriteFileAction | RenameFileAction | DeleteFileAction; + +// @public (undocumented) +export interface ActionBase { + // (undocumented) + readonly id: number; + // (undocumented) + readonly parent: number; + // (undocumented) + readonly path: Path; +} + +// @public (undocumented) +export class ActionList implements Iterable { + // (undocumented) + [Symbol.iterator](): IterableIterator; + // (undocumented) + protected _action(action: Partial): void; + // (undocumented) + create(path: Path, content: Buffer): void; + // (undocumented) + delete(path: Path): void; + // (undocumented) + find(predicate: (value: Action) => boolean): Action | null; + // (undocumented) + forEach(fn: (value: Action, index: number, array: Action[]) => void, thisArg?: {}): void; + // (undocumented) + get(i: number): Action; + // (undocumented) + has(action: Action): boolean; + // (undocumented) + get length(): number; + // (undocumented) + optimize(): void; + // (undocumented) + overwrite(path: Path, content: Buffer): void; + // (undocumented) + push(action: Action): void; + // (undocumented) + rename(path: Path, to: Path): void; +} + +// @public +export function apply(source: Source, rules: Rule[]): Source; + +// @public (undocumented) +export function applyContentTemplate(options: T): FileOperator; + +// @public (undocumented) +export function applyPathTemplate(data: T, options?: PathTemplateOptions): FileOperator; + +// @public (undocumented) +export function applyTemplates(options: T): Rule; + +// @public (undocumented) +export function applyToSubtree(path: string, rules: Rule[]): Rule; + +// @public (undocumented) +export function asSource(rule: Rule): Source; + +// @public (undocumented) +export type AsyncFileOperator = (tree: FileEntry) => Observable; + +// @public +abstract class BaseWorkflow implements Workflow { + constructor(options: BaseWorkflowOptions); + // (undocumented) + get context(): Readonly; + // (undocumented) + protected _context: WorkflowExecutionContext[]; + // (undocumented) + protected _createSinks(): Sink[]; + // (undocumented) + protected _dryRun: boolean; + // (undocumented) + get engine(): Engine<{}, {}>; + // (undocumented) + protected _engine: Engine<{}, {}>; + // (undocumented) + get engineHost(): EngineHost<{}, {}>; + // (undocumented) + protected _engineHost: EngineHost<{}, {}>; + // (undocumented) + execute(options: Partial & RequiredWorkflowExecutionContext): Observable; + // (undocumented) + protected _force: boolean; + // (undocumented) + protected _host: virtualFs.Host; + // (undocumented) + get lifeCycle(): Observable; + // (undocumented) + protected _lifeCycle: Subject; + // (undocumented) + get registry(): schema.SchemaRegistry; + // (undocumented) + protected _registry: schema.CoreSchemaRegistry; + // (undocumented) + get reporter(): Observable; + // (undocumented) + protected _reporter: Subject; +} + +// @public (undocumented) +interface BaseWorkflowOptions { + // (undocumented) + dryRun?: boolean; + // (undocumented) + engineHost: EngineHost<{}, {}>; + // (undocumented) + force?: boolean; + // (undocumented) + host: virtualFs.Host; + // (undocumented) + registry?: schema.CoreSchemaRegistry; +} + +// @public (undocumented) +export function branchAndMerge(rule: Rule, strategy?: MergeStrategy): Rule; + +// @public (undocumented) +export function callRule(rule: Rule, input: Tree_2 | Observable, context: SchematicContext): Observable; + +// @public (undocumented) +export function callSource(source: Source, context: SchematicContext): Observable; + +// @public +export function chain(rules: Iterable | AsyncIterable): Rule; + +// @public (undocumented) +export class CircularCollectionException extends BaseException { + constructor(name: string); +} + +// @public +export interface Collection { + // (undocumented) + readonly baseDescriptions?: Array>; + // (undocumented) + createSchematic(name: string, allowPrivate?: boolean): Schematic; + // (undocumented) + readonly description: CollectionDescription; + // (undocumented) + listSchematicNames(includeHidden?: boolean): string[]; +} + +// @public +export type CollectionDescription = CollectionMetadataT & { + readonly name: string; + readonly extends?: string[]; +}; + +// @public (undocumented) +export class CollectionImpl implements Collection { + constructor(_description: CollectionDescription, _engine: SchematicEngine, baseDescriptions?: CollectionDescription[] | undefined); + // (undocumented) + readonly baseDescriptions?: CollectionDescription[] | undefined; + // (undocumented) + createSchematic(name: string, allowPrivate?: boolean): Schematic; + // (undocumented) + get description(): CollectionDescription; + // (undocumented) + listSchematicNames(includeHidden?: boolean): string[]; + // (undocumented) + get name(): string; +} + +// @public (undocumented) +export function composeFileOperators(operators: FileOperator[]): FileOperator; + +// @public (undocumented) +export class ContentHasMutatedException extends BaseException { + constructor(path: string); +} + +// @public (undocumented) +export function contentTemplate(options: T): Rule; + +// @public (undocumented) +export interface CreateFileAction extends ActionBase { + // (undocumented) + readonly content: Buffer; + // (undocumented) + readonly kind: 'c'; +} + +// @public (undocumented) +export interface DelegateTree { +} + +// @public (undocumented) +export class DelegateTree implements Tree_2 { + // (undocumented) + [TreeSymbol]: () => DelegateTree; + constructor(_other: Tree_2); + // (undocumented) + get actions(): Action[]; + // (undocumented) + apply(action: Action, strategy?: MergeStrategy): void; + // (undocumented) + beginUpdate(path: string): UpdateRecorder; + // (undocumented) + branch(): Tree_2; + // (undocumented) + commitUpdate(record: UpdateRecorder): void; + // (undocumented) + create(path: string, content: Buffer | string): void; + // (undocumented) + delete(path: string): void; + // (undocumented) + exists(path: string): boolean; + // (undocumented) + get(path: string): FileEntry | null; + // (undocumented) + getDir(path: string): DirEntry; + // (undocumented) + merge(other: Tree_2, strategy?: MergeStrategy): void; + // (undocumented) + protected _other: Tree_2; + // (undocumented) + overwrite(path: string, content: Buffer | string): void; + // (undocumented) + read(path: string): Buffer | null; + // (undocumented) + readJson(path: string): JsonValue; + // (undocumented) + readText(path: string): string; + // (undocumented) + rename(from: string, to: string): void; + // (undocumented) + get root(): DirEntry; + // (undocumented) + visit(visitor: FileVisitor): void; +} + +// @public (undocumented) +export interface DeleteFileAction extends ActionBase { + // (undocumented) + readonly kind: 'd'; +} + +// @public (undocumented) +export interface DirEntry { + // (undocumented) + dir(name: PathFragment): DirEntry; + // (undocumented) + file(name: PathFragment): FileEntry | null; + // (undocumented) + readonly parent: DirEntry | null; + // (undocumented) + readonly path: Path; + // (undocumented) + readonly subdirs: PathFragment[]; + // (undocumented) + readonly subfiles: PathFragment[]; + // (undocumented) + visit(visitor: FileVisitor): void; +} + +// @public (undocumented) +export interface DryRunCreateEvent { + // (undocumented) + content: ArrayBufferLike; + // (undocumented) + kind: 'create'; + // (undocumented) + path: string; +} + +// @public (undocumented) +export interface DryRunDeleteEvent { + // (undocumented) + kind: 'delete'; + // (undocumented) + path: string; +} + +// @public (undocumented) +export interface DryRunErrorEvent { + // (undocumented) + description: 'alreadyExist' | 'doesNotExist'; + // (undocumented) + kind: 'error'; + // (undocumented) + path: string; +} + +// @public (undocumented) +export type DryRunEvent = DryRunErrorEvent | DryRunDeleteEvent | DryRunCreateEvent | DryRunUpdateEvent | DryRunRenameEvent; + +// @public (undocumented) +export interface DryRunRenameEvent { + // (undocumented) + kind: 'rename'; + // (undocumented) + path: string; + // (undocumented) + to: string; +} + +// @public (undocumented) +export class DryRunSink extends HostSink { + constructor(host: virtualFs.Host, force?: boolean); + // (undocumented) + _done(): Observable; + // (undocumented) + protected _fileAlreadyExistException(path: string): void; + // (undocumented) + protected _fileAlreadyExistExceptionSet: Set; + // (undocumented) + protected _fileDoesNotExistException(path: string): void; + // (undocumented) + protected _fileDoesNotExistExceptionSet: Set; + // (undocumented) + readonly reporter: Observable; + // (undocumented) + protected _subject: Subject; +} + +// @public (undocumented) +export interface DryRunUpdateEvent { + // (undocumented) + content: ArrayBufferLike; + // (undocumented) + kind: 'update'; + // (undocumented) + path: string; +} + +// @public +export function empty(): Source; + +// @public (undocumented) +export class EmptyTree extends HostTree { + constructor(); +} + +// @public +export interface Engine { + // (undocumented) + createCollection(name: string, requester?: Collection): Collection; + // (undocumented) + createContext(schematic: Schematic, parent?: Partial>, executionOptions?: Partial): TypedSchematicContext; + // (undocumented) + createSchematic(name: string, collection: Collection): Schematic; + // (undocumented) + createSourceFromUrl(url: Url, context: TypedSchematicContext): Source; + // (undocumented) + readonly defaultMergeStrategy: MergeStrategy; + // (undocumented) + executePostTasks(): Observable; + // (undocumented) + transformOptions(schematic: Schematic, options: OptionT, context?: TypedSchematicContext): Observable; + // (undocumented) + readonly workflow: Workflow | null; +} + +// @public +export interface EngineHost { + // (undocumented) + createCollectionDescription(name: string, requester?: CollectionDescription): CollectionDescription; + // (undocumented) + createSchematicDescription(name: string, collection: CollectionDescription): SchematicDescription | null; + // (undocumented) + createSourceFromUrl(url: Url, context: TypedSchematicContext): Source | null; + // (undocumented) + createTaskExecutor(name: string): Observable; + // (undocumented) + readonly defaultMergeStrategy?: MergeStrategy; + // (undocumented) + getSchematicRuleFactory(schematic: SchematicDescription, collection: CollectionDescription): RuleFactory; + // (undocumented) + hasTaskExecutor(name: string): boolean; + // (undocumented) + listSchematicNames(collection: CollectionDescription, includeHidden?: boolean): string[]; + // (undocumented) + transformContext(context: TypedSchematicContext): TypedSchematicContext | void; + // (undocumented) + transformOptions(schematic: SchematicDescription, options: OptionT, context?: TypedSchematicContext): Observable; +} + +// @public (undocumented) +export interface ExecutionOptions { + // (undocumented) + interactive: boolean; + // (undocumented) + scope: string; +} + +// @public +export function externalSchematic(collectionName: string, schematicName: string, options: OptionT, executionOptions?: Partial): Rule; + +// @public (undocumented) +export class FileAlreadyExistException extends BaseException { + constructor(path: string); +} + +// @public (undocumented) +export class FileDoesNotExistException extends BaseException { + constructor(path: string); +} + +// @public (undocumented) +export interface FileEntry { + // (undocumented) + readonly content: Buffer; + // (undocumented) + readonly path: Path; +} + +// @public +export type FileOperator = (entry: FileEntry) => FileEntry | null; + +// @public (undocumented) +export interface FilePredicate { + // (undocumented) + (path: Path, entry?: Readonly | null): T; +} + +// @public (undocumented) +export type FileVisitor = FilePredicate; + +// @public (undocumented) +export const FileVisitorCancelToken: symbol; + +// @public (undocumented) +export function filter(predicate: FilePredicate): Rule; + +// @public (undocumented) +export class FilterHostTree extends HostTree { + constructor(tree: HostTree, filter?: FilePredicate); +} + +// @public (undocumented) +export function forEach(operator: FileOperator): Rule; + +declare namespace formats { + export { + htmlSelectorFormat, + pathFormat, + standardFormats + } +} +export { formats } + +// @public (undocumented) +export class HostCreateTree extends HostTree { + constructor(host: virtualFs.ReadonlyHost); +} + +// @public (undocumented) +export class HostDirEntry implements DirEntry { + constructor(parent: DirEntry | null, path: Path, _host: virtualFs.SyncDelegateHost, _tree: Tree_2); + // (undocumented) + dir(name: PathFragment): DirEntry; + // (undocumented) + file(name: PathFragment): FileEntry | null; + // (undocumented) + protected _host: virtualFs.SyncDelegateHost; + // (undocumented) + readonly parent: DirEntry | null; + // (undocumented) + readonly path: Path; + // (undocumented) + get subdirs(): PathFragment[]; + // (undocumented) + get subfiles(): PathFragment[]; + // (undocumented) + protected _tree: Tree_2; + // (undocumented) + visit(visitor: FileVisitor): void; +} + +// @public (undocumented) +export class HostSink extends SimpleSinkBase { + constructor(_host: virtualFs.Host, _force?: boolean); + // (undocumented) + protected _createFile(path: Path, content: Buffer): Observable; + // (undocumented) + protected _deleteFile(path: Path): Observable; + // (undocumented) + _done(): Observable; + // (undocumented) + protected _filesToCreate: Map; + // (undocumented) + protected _filesToDelete: Set; + // (undocumented) + protected _filesToRename: Set<[Path, Path]>; + // (undocumented) + protected _filesToUpdate: Map; + // (undocumented) + protected _force: boolean; + // (undocumented) + protected _host: virtualFs.Host; + // (undocumented) + protected _overwriteFile(path: Path, content: Buffer): Observable; + // (undocumented) + protected _renameFile(from: Path, to: Path): Observable; + // (undocumented) + protected _validateCreateAction(action: CreateFileAction): Observable; + // (undocumented) + protected _validateFileExists(p: Path): Observable; +} + +// @public (undocumented) +export interface HostTree { +} + +// @public (undocumented) +export class HostTree implements Tree_2 { + // (undocumented) + [TreeSymbol]: () => HostTree; + constructor(_backend?: virtualFs.ReadonlyHost<{}>); + // (undocumented) + get actions(): Action[]; + // (undocumented) + apply(action: Action, strategy?: MergeStrategy): void; + // (undocumented) + protected _backend: virtualFs.ReadonlyHost<{}>; + // (undocumented) + beginUpdate(path: string): UpdateRecorder; + // (undocumented) + branch(): Tree_2; + // (undocumented) + commitUpdate(record: UpdateRecorder): void; + // (undocumented) + create(path: string, content: Buffer | string): void; + // (undocumented) + delete(path: string): void; + // (undocumented) + exists(path: string): boolean; + // (undocumented) + get(path: string): FileEntry | null; + // (undocumented) + getDir(path: string): DirEntry; + // (undocumented) + static isHostTree(tree: Tree_2): tree is HostTree; + // (undocumented) + merge(other: Tree_2, strategy?: MergeStrategy): void; + // (undocumented) + protected _normalizePath(path: string): Path; + // (undocumented) + overwrite(path: string, content: Buffer | string): void; + // (undocumented) + read(path: string): Buffer | null; + // (undocumented) + readJson(path: string): JsonValue; + // (undocumented) + readText(path: string): string; + // (undocumented) + rename(from: string, to: string): void; + // (undocumented) + get root(): DirEntry; + // (undocumented) + visit(visitor: FileVisitor): void; + // (undocumented) + protected _willCreate(path: Path): boolean; + // (undocumented) + protected _willDelete(path: Path): boolean; + // (undocumented) + protected _willOverwrite(path: Path): boolean; + // (undocumented) + protected _willRename(path: Path): boolean; +} + +// @public (undocumented) +const htmlSelectorFormat: schema.SchemaFormat; + +// @public (undocumented) +export class InvalidPipeException extends BaseException { + constructor(name: string); +} + +// @public +export class InvalidRuleResultException extends BaseException { + constructor(value?: {}); +} + +// @public (undocumented) +export class InvalidSchematicsNameException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export class InvalidSourceResultException extends BaseException { + constructor(value?: {}); +} + +// @public (undocumented) +export class InvalidUpdateRecordException extends BaseException { + constructor(); +} + +// @public (undocumented) +export function isContentAction(action: Action): action is CreateFileAction | OverwriteFileAction; + +// @public (undocumented) +interface LifeCycleEvent { + // (undocumented) + kind: 'start' | 'end' | 'workflow-start' | 'workflow-end' | 'post-tasks-start' | 'post-tasks-end'; +} + +// @public (undocumented) +export class MergeConflictException extends BaseException { + constructor(path: string); +} + +// @public (undocumented) +export enum MergeStrategy { + // (undocumented) + AllowCreationConflict = 4, + // (undocumented) + AllowDeleteConflict = 8, + // (undocumented) + AllowOverwriteConflict = 2, + // (undocumented) + ContentOnly = 2, + // (undocumented) + Default = 0, + // (undocumented) + Error = 1, + // (undocumented) + Overwrite = 14 +} + +// @public +export function mergeWith(source: Source, strategy?: MergeStrategy): Rule; + +// @public (undocumented) +export function move(from: string, to: string): Rule; + +// @public (undocumented) +export function move(to: string): Rule; + +// @public (undocumented) +export function noop(): Rule; + +// @public (undocumented) +export class OptionIsNotDefinedException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export interface OverwriteFileAction extends ActionBase { + // (undocumented) + readonly content: Buffer; + // (undocumented) + readonly kind: 'o'; +} + +// @public (undocumented) +export function partitionApplyMerge(predicate: FilePredicate, ruleYes: Rule, ruleNo?: Rule): Rule; + +// @public (undocumented) +const pathFormat: schema.SchemaFormat; + +// @public (undocumented) +export function pathTemplate(options: T): Rule; + +// @public (undocumented) +export type PathTemplateData = { + [key: string]: PathTemplateValue | PathTemplateData | PathTemplatePipeFunction; +}; + +// @public (undocumented) +export interface PathTemplateOptions { + // (undocumented) + interpolationEnd: string; + // (undocumented) + interpolationStart: string; + // (undocumented) + pipeSeparator?: string; +} + +// @public (undocumented) +export type PathTemplatePipeFunction = (x: string) => PathTemplateValue; + +// @public (undocumented) +export type PathTemplateValue = boolean | string | number | undefined; + +// @public (undocumented) +export class PrivateSchematicException extends BaseException { + constructor(name: string, collection: CollectionDescription<{}>); +} + +// @public (undocumented) +export interface RandomOptions { + // (undocumented) + multi?: boolean | number; + // (undocumented) + multiFiles?: boolean | number; + // (undocumented) + root?: string; +} + +// @public (undocumented) +export interface RenameFileAction extends ActionBase { + // (undocumented) + readonly kind: 'r'; + // (undocumented) + readonly to: Path; +} + +// @public +export function renameTemplateFiles(): Rule; + +// @public (undocumented) +interface RequiredWorkflowExecutionContext { + // (undocumented) + collection: string; + // (undocumented) + options: object; + // (undocumented) + schematic: string; +} + +// @public (undocumented) +export type Rule = (tree: Tree_2, context: SchematicContext) => Tree_2 | Observable | Rule | Promise | void; + +// @public +export type RuleFactory = (options: T) => Rule; + +// @public +export interface Schematic { + // (undocumented) + call(options: OptionT, host: Observable, parentContext?: Partial>, executionOptions?: Partial): Observable; + // (undocumented) + readonly collection: Collection; + // (undocumented) + readonly description: SchematicDescription; +} + +// @public +export function schematic(schematicName: string, options: OptionT, executionOptions?: Partial): Rule; + +// @public +export type SchematicContext = TypedSchematicContext<{}, {}>; + +// @public +export type SchematicDescription = SchematicMetadataT & { + readonly collection: CollectionDescription; + readonly name: string; + readonly private?: boolean; + readonly hidden?: boolean; +}; + +// @public (undocumented) +export class SchematicEngine implements Engine { + constructor(_host: EngineHost, _workflow?: Workflow | undefined); + // (undocumented) + createCollection(name: string, requester?: Collection): Collection; + // (undocumented) + createContext(schematic: Schematic, parent?: Partial>, executionOptions?: Partial): TypedSchematicContext; + // (undocumented) + createSchematic(name: string, collection: Collection, allowPrivate?: boolean): Schematic; + // (undocumented) + createSourceFromUrl(url: Url, context: TypedSchematicContext): Source; + // (undocumented) + get defaultMergeStrategy(): MergeStrategy; + // (undocumented) + executePostTasks(): Observable; + // (undocumented) + listSchematicNames(collection: Collection, includeHidden?: boolean): string[]; + // (undocumented) + transformOptions(schematic: Schematic, options: OptionT, context?: TypedSchematicContext): Observable; + // (undocumented) + get workflow(): Workflow | null; + // (undocumented) + protected _workflow?: Workflow | undefined; +} + +// @public (undocumented) +export class SchematicEngineConflictingException extends BaseException { + constructor(); +} + +// @public (undocumented) +export class SchematicImpl implements Schematic { + constructor(_description: SchematicDescription, _factory: RuleFactory<{}>, _collection: Collection, _engine: Engine); + // (undocumented) + call(options: OptionT, host: Observable, parentContext?: Partial>, executionOptions?: Partial): Observable; + // (undocumented) + get collection(): Collection; + // (undocumented) + get description(): SchematicDescription; +} + +// @public (undocumented) +export class SchematicsException extends BaseException { +} + +// @public (undocumented) +export abstract class SimpleSinkBase implements Sink { + // (undocumented) + commit(tree: Tree_2): Observable; + // (undocumented) + commitSingleAction(action: Action): Observable; + // (undocumented) + protected abstract _createFile(path: string, content: Buffer): Observable; + // (undocumented) + protected abstract _deleteFile(path: string): Observable; + // (undocumented) + protected abstract _done(): Observable; + // (undocumented) + protected _fileAlreadyExistException(path: string): void; + // (undocumented) + protected _fileDoesNotExistException(path: string): void; + // (undocumented) + protected abstract _overwriteFile(path: string, content: Buffer): Observable; + // (undocumented) + postCommit: () => void | Observable; + // (undocumented) + postCommitAction: (action: Action) => void | Observable; + // (undocumented) + preCommit: () => void | Observable; + // (undocumented) + preCommitAction: (action: Action) => void | Action | PromiseLike | Observable; + // (undocumented) + protected abstract _renameFile(path: string, to: string): Observable; + // (undocumented) + protected _validateCreateAction(action: CreateFileAction): Observable; + // (undocumented) + protected _validateDeleteAction(action: DeleteFileAction): Observable; + // (undocumented) + protected abstract _validateFileExists(p: string): Observable; + // (undocumented) + protected _validateOverwriteAction(action: OverwriteFileAction): Observable; + // (undocumented) + protected _validateRenameAction(action: RenameFileAction): Observable; + // (undocumented) + validateSingleAction(action: Action): Observable; +} + +// @public (undocumented) +export interface Sink { + // (undocumented) + commit(tree: Tree_2): Observable; +} + +// @public +export type Source = (context: SchematicContext) => Tree_2 | Observable; + +// @public +export function source(tree: Tree_2): Source; + +// @public (undocumented) +const standardFormats: schema.SchemaFormat[]; + +export { strings } + +// @public (undocumented) +export interface TaskConfiguration { + // (undocumented) + dependencies?: Array; + // (undocumented) + name: string; + // (undocumented) + options?: T; +} + +// @public (undocumented) +export interface TaskConfigurationGenerator { + // (undocumented) + toConfiguration(): TaskConfiguration; +} + +// @public (undocumented) +export type TaskExecutor = (options: T | undefined, context: SchematicContext) => Promise | Observable; + +// @public (undocumented) +export interface TaskExecutorFactory { + // (undocumented) + create(options?: T): Promise | Observable; + // (undocumented) + readonly name: string; +} + +// @public (undocumented) +export interface TaskId { + // (undocumented) + readonly id: number; +} + +// @public (undocumented) +export interface TaskInfo { + // (undocumented) + readonly configuration: TaskConfiguration; + // (undocumented) + readonly context: SchematicContext; + // (undocumented) + readonly id: number; + // (undocumented) + readonly priority: number; +} + +// @public (undocumented) +export class TaskScheduler { + constructor(_context: SchematicContext); + // (undocumented) + finalize(): ReadonlyArray; + // (undocumented) + schedule(taskConfiguration: TaskConfiguration): TaskId; +} + +// @public (undocumented) +export function template(options: T): Rule; + +// @public (undocumented) +export const TEMPLATE_FILENAME_RE: RegExp; + +// @public (undocumented) +export type Tree = Tree_2; + +// @public (undocumented) +export const Tree: TreeConstructor; + +// @public (undocumented) +export interface TreeConstructor { + // (undocumented) + branch(tree: Tree_2): Tree_2; + // (undocumented) + empty(): Tree_2; + // (undocumented) + merge(tree: Tree_2, other: Tree_2, strategy?: MergeStrategy): Tree_2; + // (undocumented) + optimize(tree: Tree_2): Tree_2; + // (undocumented) + partition(tree: Tree_2, predicate: FilePredicate): [Tree_2, Tree_2]; +} + +// @public (undocumented) +export const TreeSymbol: symbol; + +// @public +export interface TypedSchematicContext { + // (undocumented) + addTask(task: TaskConfigurationGenerator, dependencies?: Array): TaskId; + // (undocumented) + readonly debug: boolean; + // (undocumented) + readonly engine: Engine; + // (undocumented) + readonly interactive: boolean; + // (undocumented) + readonly logger: logging.LoggerApi; + // (undocumented) + readonly schematic: Schematic; + // (undocumented) + readonly strategy: MergeStrategy; +} + +// @public (undocumented) +export class UnimplementedException extends BaseException { + constructor(); +} + +// @public (undocumented) +export class UnknownActionException extends BaseException { + constructor(action: Action); +} + +// @public (undocumented) +export class UnknownCollectionException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export class UnknownPipeException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export class UnknownSchematicException extends BaseException { + constructor(name: string, collection: CollectionDescription<{}>); +} + +// @public (undocumented) +export class UnknownTaskDependencyException extends BaseException { + constructor(id: TaskId); +} + +// @public (undocumented) +export class UnknownUrlSourceProtocol extends BaseException { + constructor(url: string); +} + +// @public (undocumented) +export class UnregisteredTaskException extends BaseException { + constructor(name: string, schematic?: SchematicDescription<{}, {}>); +} + +// @public (undocumented) +export class UnsuccessfulWorkflowExecution extends BaseException { + constructor(); +} + +// @public (undocumented) +export interface UpdateRecorder { + // (undocumented) + insertLeft(index: number, content: Buffer | string): UpdateRecorder; + // (undocumented) + insertRight(index: number, content: Buffer | string): UpdateRecorder; + // (undocumented) + remove(index: number, length: number): UpdateRecorder; +} + +// @public (undocumented) +export function url(urlString: string): Source; + +// @public (undocumented) +export function when(predicate: FilePredicate, operator: FileOperator): FileOperator; + +// @public (undocumented) +interface Workflow { + // (undocumented) + readonly context: Readonly; + // (undocumented) + execute(options: Partial & RequiredWorkflowExecutionContext): Observable; +} + +declare namespace workflow { + export { + BaseWorkflowOptions, + BaseWorkflow, + RequiredWorkflowExecutionContext, + WorkflowExecutionContext, + LifeCycleEvent, + Workflow + } +} +export { workflow } + +// @public (undocumented) +interface WorkflowExecutionContext extends RequiredWorkflowExecutionContext { + // (undocumented) + allowPrivate?: boolean; + // (undocumented) + debug: boolean; + // (undocumented) + logger: logging.Logger; + // (undocumented) + parentContext?: Readonly; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/schematics/tasks/index.api.md b/goldens/public-api/angular_devkit/schematics/tasks/index.api.md new file mode 100644 index 000000000000..ad9b7d8d7f0b --- /dev/null +++ b/goldens/public-api/angular_devkit/schematics/tasks/index.api.md @@ -0,0 +1,54 @@ +## API Report File for "@angular-devkit/schematics_tasks" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @public (undocumented) +export class NodePackageInstallTask implements TaskConfigurationGenerator { + constructor(workingDirectory?: string); + constructor(options: NodePackageInstallTaskOptions); + // (undocumented) + allowScripts: boolean; + // (undocumented) + hideOutput: boolean; + // (undocumented) + packageManager?: string; + // (undocumented) + packageName?: string; + // (undocumented) + quiet: boolean; + // (undocumented) + toConfiguration(): TaskConfiguration; + // (undocumented) + workingDirectory?: string; +} + +// @public (undocumented) +export class RepositoryInitializerTask implements TaskConfigurationGenerator { + constructor(workingDirectory?: string | undefined, commitOptions?: CommitOptions | undefined); + // (undocumented) + commitOptions?: CommitOptions | undefined; + // (undocumented) + toConfiguration(): TaskConfiguration; + // (undocumented) + workingDirectory?: string | undefined; +} + +// @public (undocumented) +export class RunSchematicTask implements TaskConfigurationGenerator> { + constructor(s: string, o: T); + constructor(c: string, s: string, o: T); + // (undocumented) + protected _collection: string | null; + // (undocumented) + protected _options: T; + // (undocumented) + protected _schematic: string; + // (undocumented) + toConfiguration(): TaskConfiguration>; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/schematics/testing/index.api.md b/goldens/public-api/angular_devkit/schematics/testing/index.api.md new file mode 100644 index 000000000000..abf89972c862 --- /dev/null +++ b/goldens/public-api/angular_devkit/schematics/testing/index.api.md @@ -0,0 +1,43 @@ +## API Report File for "@angular-devkit/schematics_testing" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { JsonValue } from '@angular-devkit/core'; +import { logging } from '@angular-devkit/core'; +import { Observable } from 'rxjs'; +import { Path } from '@angular-devkit/core'; +import { PathFragment } from '@angular-devkit/core'; +import { Url } from 'node:url'; + +// @public (undocumented) +export class SchematicTestRunner { + constructor(_collectionName: string, collectionPath: string); + // (undocumented) + callRule(rule: Rule, tree: Tree_2, parentContext?: Partial): Observable; + // (undocumented) + get engine(): SchematicEngine<{}, {}>; + // (undocumented) + get logger(): logging.Logger; + // (undocumented) + registerCollection(collectionName: string, collectionPath: string): void; + // (undocumented) + runExternalSchematic(collectionName: string, schematicName: string, opts?: SchematicSchemaT, tree?: Tree_2): Promise; + // (undocumented) + runSchematic(schematicName: string, opts?: SchematicSchemaT, tree?: Tree_2): Promise; + // (undocumented) + get tasks(): TaskConfiguration[]; +} + +// @public (undocumented) +export class UnitTestTree extends DelegateTree { + // (undocumented) + get files(): string[]; + // (undocumented) + readContent(path: string): string; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/schematics/tools/index.api.md b/goldens/public-api/angular_devkit/schematics/tools/index.api.md new file mode 100644 index 000000000000..c93e3ec27762 --- /dev/null +++ b/goldens/public-api/angular_devkit/schematics/tools/index.api.md @@ -0,0 +1,280 @@ +## API Report File for "@angular-devkit/schematics_tools" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BaseException } from '@angular-devkit/core'; +import { JsonObject } from '@angular-devkit/core'; +import { JsonValue } from '@angular-devkit/core'; +import { logging } from '@angular-devkit/core'; +import { Observable } from 'rxjs'; +import { Path } from '@angular-devkit/core'; +import { PathFragment } from '@angular-devkit/core'; +import { schema } from '@angular-devkit/core'; +import { Subject } from 'rxjs'; +import { Url } from 'node:url'; +import { virtualFs } from '@angular-devkit/core'; + +// @public (undocumented) +export class CollectionCannotBeResolvedException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export class CollectionMissingFieldsException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export class CollectionMissingSchematicsMapException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export type ContextTransform = (context: FileSystemSchematicContext) => FileSystemSchematicContext; + +// @public +export class ExportStringRef { + constructor(ref: string, parentPath?: string, inner?: boolean); + // (undocumented) + get module(): string; + // (undocumented) + get path(): string; + // (undocumented) + get ref(): T | undefined; +} + +// @public (undocumented) +export class FactoryCannotBeResolvedException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export type FileSystemCollection = Collection; + +// @public (undocumented) +export type FileSystemCollectionDesc = CollectionDescription; + +// @public (undocumented) +export interface FileSystemCollectionDescription { + // (undocumented) + readonly encapsulation?: boolean; + // (undocumented) + readonly name: string; + // (undocumented) + readonly path: string; + // (undocumented) + readonly schematics: { + [name: string]: FileSystemSchematicDesc; + }; + // (undocumented) + readonly version?: string; +} + +// @public +export type FileSystemEngine = Engine; + +// @public +export class FileSystemEngineHost extends FileSystemEngineHostBase { + constructor(_root: string); + // (undocumented) + createTaskExecutor(name: string): Observable; + // (undocumented) + hasTaskExecutor(name: string): boolean; + // (undocumented) + protected _resolveCollectionPath(name: string): string; + // (undocumented) + protected _resolveReferenceString(refString: string, parentPath: string): { + ref: RuleFactory<{}>; + path: string; + } | null; + // (undocumented) + protected _root: string; + // (undocumented) + protected _transformCollectionDescription(name: string, desc: Partial): FileSystemCollectionDesc; + // (undocumented) + protected _transformSchematicDescription(name: string, _collection: FileSystemCollectionDesc, desc: Partial): FileSystemSchematicDesc; +} + +// @public +export abstract class FileSystemEngineHostBase implements FileSystemEngineHost_2 { + // (undocumented) + createCollectionDescription(name: string, requester?: FileSystemCollectionDesc): FileSystemCollectionDesc; + // (undocumented) + createSchematicDescription(name: string, collection: FileSystemCollectionDesc): FileSystemSchematicDesc | null; + // (undocumented) + createSourceFromUrl(url: Url): Source | null; + // (undocumented) + createTaskExecutor(name: string): Observable; + // (undocumented) + getSchematicRuleFactory(schematic: FileSystemSchematicDesc, _collection: FileSystemCollectionDesc): RuleFactory; + // (undocumented) + hasTaskExecutor(name: string): boolean; + // (undocumented) + listSchematicNames(collection: FileSystemCollectionDesc, includeHidden?: boolean): string[]; + // (undocumented) + registerContextTransform(t: ContextTransform): void; + // (undocumented) + registerOptionsTransform(t: OptionTransform): void; + // (undocumented) + registerTaskExecutor(factory: TaskExecutorFactory, options?: T): void; + // (undocumented) + protected abstract _resolveCollectionPath(name: string, requester?: string): string; + // (undocumented) + protected abstract _resolveReferenceString(name: string, parentPath: string, collectionDescription: FileSystemCollectionDesc): { + ref: RuleFactory<{}>; + path: string; + } | null; + // (undocumented) + protected abstract _transformCollectionDescription(name: string, desc: Partial): FileSystemCollectionDesc; + // (undocumented) + transformContext(context: FileSystemSchematicContext): FileSystemSchematicContext; + // (undocumented) + transformOptions(schematic: FileSystemSchematicDesc, options: OptionT, context?: FileSystemSchematicContext): Observable; + // (undocumented) + protected abstract _transformSchematicDescription(name: string, collection: FileSystemCollectionDesc, desc: Partial): FileSystemSchematicDesc; +} + +// @public (undocumented) +export type FileSystemSchematic = Schematic; + +// @public (undocumented) +export type FileSystemSchematicContext = TypedSchematicContext; + +// @public (undocumented) +export type FileSystemSchematicDesc = SchematicDescription; + +// @public (undocumented) +export interface FileSystemSchematicDescription extends FileSystemSchematicJsonDescription { + // (undocumented) + readonly factoryFn: RuleFactory<{}>; + // (undocumented) + readonly path: string; + // (undocumented) + readonly schemaJson?: JsonObject; +} + +// @public (undocumented) +export interface FileSystemSchematicJsonDescription { + // (undocumented) + readonly aliases?: string[]; + // (undocumented) + readonly collection: FileSystemCollectionDescription; + // (undocumented) + readonly description: string; + // (undocumented) + readonly extends?: string; + // (undocumented) + readonly factory: string; + // (undocumented) + readonly name: string; + // (undocumented) + readonly schema?: string; +} + +// @public (undocumented) +export class InvalidCollectionJsonException extends BaseException { + constructor(_name: string, path: string, jsonException?: Error); +} + +// @public +export class NodeModulesEngineHost extends FileSystemEngineHostBase { + constructor(paths?: string[] | undefined); + // (undocumented) + protected _resolveCollectionPath(name: string, requester?: string): string; + // (undocumented) + protected _resolveReferenceString(refString: string, parentPath: string, collectionDescription?: FileSystemCollectionDesc): { + ref: RuleFactory<{}>; + path: string; + } | null; + // (undocumented) + protected _transformCollectionDescription(name: string, desc: Partial): FileSystemCollectionDesc; + // (undocumented) + protected _transformSchematicDescription(name: string, _collection: FileSystemCollectionDesc, desc: Partial): FileSystemSchematicDesc; +} + +// @public +export class NodeModulesTestEngineHost extends NodeModulesEngineHost { + // (undocumented) + clearTasks(): void; + // (undocumented) + registerCollection(name: string, path: string): void; + // (undocumented) + protected _resolveCollectionPath(name: string, requester?: string): string; + // (undocumented) + get tasks(): TaskConfiguration[]; + // (undocumented) + transformContext(context: FileSystemSchematicContext): FileSystemSchematicContext; +} + +// @public (undocumented) +export class NodePackageDoesNotSupportSchematics extends BaseException { + constructor(name: string); +} + +// @public +export class NodeWorkflow extends workflow.BaseWorkflow { + constructor(root: string, options: NodeWorkflowOptions); + constructor(host: virtualFs.Host, options: NodeWorkflowOptions & { + root?: Path; + }); + // (undocumented) + get engine(): FileSystemEngine; + // (undocumented) + get engineHost(): NodeModulesEngineHost; +} + +// @public (undocumented) +export interface NodeWorkflowOptions { + // (undocumented) + dryRun?: boolean; + // (undocumented) + engineHostCreator?: (options: NodeWorkflowOptions) => NodeModulesEngineHost; + // (undocumented) + force?: boolean; + // (undocumented) + optionTransforms?: OptionTransform | null, object>[]; + // (undocumented) + packageManager?: string; + // (undocumented) + packageManagerForce?: boolean; + // (undocumented) + packageRegistry?: string; + // (undocumented) + registry?: schema.CoreSchemaRegistry; + // (undocumented) + resolvePaths?: string[]; + // (undocumented) + schemaValidation?: boolean; +} + +// @public (undocumented) +export type OptionTransform = (schematic: FileSystemSchematicDescription, options: T, context?: FileSystemSchematicContext) => Observable | PromiseLike | R; + +// @public (undocumented) +export class SchematicMissingDescriptionException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export class SchematicMissingFactoryException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export class SchematicMissingFieldsException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export class SchematicNameCollisionException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export function validateOptionsWithSchema(registry: schema.SchemaRegistry): (schematic: FileSystemSchematicDescription, options: T, context?: FileSystemSchematicContext) => Observable; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/ngtools/webpack/index.api.md b/goldens/public-api/ngtools/webpack/index.api.md new file mode 100644 index 000000000000..a2c25e8c74f5 --- /dev/null +++ b/goldens/public-api/ngtools/webpack/index.api.md @@ -0,0 +1,56 @@ +## API Report File for "@ngtools/webpack" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { Compiler } from 'webpack'; +import type { CompilerOptions } from '@angular/compiler-cli'; +import type { LoaderContext } from 'webpack'; + +// @public @deprecated (undocumented) +function angularWebpackLoader(this: LoaderContext, content: string, map: string): void; +export default angularWebpackLoader; + +// @public @deprecated (undocumented) +export const AngularWebpackLoaderPath: string; + +// @public @deprecated (undocumented) +export class AngularWebpackPlugin { + constructor(options?: Partial); + // (undocumented) + apply(compiler: Compiler): void; + // (undocumented) + get options(): AngularWebpackPluginOptions; +} + +// @public @deprecated (undocumented) +export interface AngularWebpackPluginOptions { + // (undocumented) + compilerOptions?: CompilerOptions; + // (undocumented) + directTemplateLoading: boolean; + // (undocumented) + emitClassMetadata: boolean; + // (undocumented) + emitNgModuleScope: boolean; + // (undocumented) + emitSetClassDebugInfo?: boolean; + // (undocumented) + fileReplacements: Record; + // (undocumented) + inlineStyleFileExtension?: string; + // (undocumented) + jitMode: boolean; + // (undocumented) + substitutions: Record; + // (undocumented) + tsconfig: string; +} + +// @public @deprecated (undocumented) +export const imageDomains: Set; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/lib/README.md b/lib/README.md deleted file mode 100644 index bcb80b40bd17..000000000000 --- a/lib/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# `/lib` Folder - -This folder includes bootstrap code for the various tools included in this repository. Also -included is the packages meta-information package in `packages.ts`. This is used to read and -understand all the monorepo information (contained in the `.monorepo.json` file, and `package.json` -files across the repo). - -`bootstrap-local.js` should be included when running files from this repository without compiling -first. It allows for compiling and loading packages in memory directly from the repo. Not only -does the `devkit-admin` scripts use this to include the library, but also all binaries linked -locally (like `schematics` and `ng`), when not using the npm published packages. - -`istanbul-local.js` adds global hooks and information to be able to use code coverage with -`bootstrap-local.js`. Istanbul does not keep the sourcemaps properly in sync if they're available -in the code, which happens locally when transpiling TypeScript. It is currently used by the -`test` script and is included by `bootstrap-local.js` for integration. diff --git a/lib/bootstrap-local.js b/lib/bootstrap-local.js deleted file mode 100644 index e0772ea11740..000000000000 --- a/lib/bootstrap-local.js +++ /dev/null @@ -1,206 +0,0 @@ -/** - * @license - * 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 - */ -/* eslint-disable no-console */ -'use strict'; - -const child_process = require('child_process'); -const fs = require('fs'); -const path = require('path'); -const temp = require('temp'); -const ts = require('typescript'); - -const tmpRoot = temp.mkdirSync('angular-devkit-'); - -let _istanbulRequireHook = null; -if (process.env['CODE_COVERAGE'] || process.argv.indexOf('--code-coverage') !== -1) { - _istanbulRequireHook = require('./istanbul-local').istanbulRequireHook; -} - - -// Check if we need to profile this CLI run. -let profiler = null; -if (process.env['DEVKIT_PROFILING']) { - try { - profiler = require('v8-profiler-node8'); - } catch (err) { - throw new Error(`Could not require 'v8-profiler-node8'. You must install it separetely with` + - `'npm install v8-profiler-node8 --no-save.\n\nOriginal error:\n\n${err}`); - } - - profiler.startProfiling(); - - function exitHandler(options, _err) { - if (options.cleanup) { - const cpuProfile = profiler.stopProfiling(); - const profileData = JSON.stringify(cpuProfile); - const filePath = path.resolve(process.cwd(), process.env.DEVKIT_PROFILING) + '.cpuprofile'; - - console.log(`Profiling data saved in "${filePath}": ${profileData.length} bytes`); - fs.writeFileSync(filePath, profileData); - } - - if (options.exit) { - process.exit(); - } - } - - process.on('exit', exitHandler.bind(null, { cleanup: true })); - process.on('SIGINT', exitHandler.bind(null, { exit: true })); - process.on('uncaughtException', exitHandler.bind(null, { exit: true })); -} - -if (process.env['DEVKIT_LONG_STACK_TRACE']) { - Error.stackTraceLimit = Infinity; -} - -global._DevKitIsLocal = true; -global._DevKitRoot = path.resolve(__dirname, '..'); - - -const compilerOptions = ts.readConfigFile(path.join(__dirname, '../tsconfig.json'), p => { - return fs.readFileSync(p, 'utf-8'); -}).config; - - -const oldRequireTs = require.extensions['.ts']; -require.extensions['.ts'] = function (m, filename) { - // If we're in node module, either call the old hook or simply compile the - // file without transpilation. We do not touch node_modules/**. - // We do touch `Angular DevK` files anywhere though. - if (!filename.match(/@angular\/cli\b/) && filename.match(/node_modules/)) { - if (oldRequireTs) { - return oldRequireTs(m, filename); - } - return m._compile(fs.readFileSync(filename), filename); - } - - // Node requires all require hooks to be sync. - const source = fs.readFileSync(filename).toString(); - - try { - let result = ts.transpile(source, compilerOptions['compilerOptions'], filename); - - if (_istanbulRequireHook) { - result = _istanbulRequireHook(result, filename); - } - - // Send it to node to execute. - return m._compile(result, filename); - } catch (err) { - console.error('Error while running script "' + filename + '":'); - console.error(err.stack); - throw err; - } -}; - - -require.extensions['.ejs'] = function (m, filename) { - const source = fs.readFileSync(filename).toString(); - const template = require('@angular-devkit/core').template; - const result = template(source, { sourceURL: filename, sourceMap: true }); - - return m._compile(result.source.replace(/return/, 'module.exports.default = '), filename); -}; - -const builtinModules = Object.keys(process.binding('natives')); -const packages = require('./packages').packages; -// If we're running locally, meaning npm linked. This is basically "developer mode". -if (!__dirname.match(new RegExp(`\\${path.sep}node_modules\\${path.sep}`))) { - - // We mock the module loader so that we can fake our packages when running locally. - const Module = require('module'); - const oldLoad = Module._load; - const oldResolve = Module._resolveFilename; - - Module._resolveFilename = function (request, parent) { - let resolved = null; - let exception; - try { - resolved = oldResolve.call(this, request, parent); - } catch (e) { - exception = e; - } - - if (request in packages) { - return packages[request].main; - } else if (builtinModules.includes(request)) { - // It's a native Node module. - return oldResolve.call(this, request, parent); - } else if (resolved && resolved.match(/[\\\/]node_modules[\\\/]/)) { - return resolved; - } else { - const match = Object.keys(packages).find(pkgName => request.startsWith(pkgName + '/')); - if (match) { - const p = path.join(packages[match].root, request.substr(match.length)); - return oldResolve.call(this, p, parent); - } else if (!resolved) { - if (exception) { - throw exception; - } else { - return resolved; - } - } else { - // Because loading `.ts` ends up AFTER `.json` in the require() logic, requiring a file that has both `.json` - // and `.ts` versions will only get the `.json` content (which wouldn't happen if the .ts was compiled to - // JavaScript). We load `.ts` files first here to avoid this conflict. It's hacky, but so is the rest of this - // file. - const maybeTsPath = resolved.endsWith('.json') && resolved.replace(/\.json$/, '.ts'); - if (maybeTsPath && !request.endsWith('.json')) { - // If the file exists, return its path. If it doesn't, run the quicktype runner on it and return the content. - if (fs.existsSync(maybeTsPath)) { - return maybeTsPath; - } else { - // This script has the be synchronous, so we spawnSync instead of, say, requiring the runner and calling - // the method directly. - const tmpJsonSchemaPath = path.join(tmpRoot, maybeTsPath.replace(/[^0-9a-zA-Z.]/g, '_')); - try { - if (!fs.existsSync(tmpJsonSchemaPath)) { - const quicktypeRunnerPath = path.join(__dirname, '../tools/quicktype_runner.js'); - child_process.spawnSync('node', [quicktypeRunnerPath, resolved, tmpJsonSchemaPath]); - } - - return tmpJsonSchemaPath; - } catch (_) { - // Just return resolvedPath and let Node deals with it. - console.log(_); - process.exit(99); - } - } - } - - return resolved; - } - } - }; -} - - -// Set the resolve hook to allow resolution of packages from a local dev environment. -require('@angular-devkit/core/node/resolve').setResolveHook(function(request, options) { - try { - if (request in packages) { - if (options.resolvePackageJson) { - return path.join(packages[request].root, 'package.json'); - } else { - return packages[request].main; - } - } else { - const match = Object.keys(packages).find(function(pkgName) { - return request.startsWith(pkgName + '/'); - }); - - if (match) { - return path.join(packages[match].root, request.substr(match[0].length)); - } else { - return null; - } - } - } catch (_) { - return null; - } -}); diff --git a/lib/istanbul-local.js b/lib/istanbul-local.js deleted file mode 100644 index ef5782661a92..000000000000 --- a/lib/istanbul-local.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @license - * 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 - */ -const { SourceMapConsumer } = require('source-map'); -const Istanbul = require('istanbul'); - -const inlineSourceMapRe = /\/\/# sourceMappingURL=data:application\/json;base64,(\S+)$/; - - -// Use the internal DevKit Hook of the require extension installed by our bootstrapping code to add -// Istanbul (not Constantinople) collection to the code. -const codeMap = new Map(); -exports.codeMap = codeMap; - -exports.istanbulRequireHook = function(code, filename) { - // Skip spec files. - if (filename.match(/_spec(_large)?\.ts$/)) { - return code; - } - const codeFile = codeMap.get(filename); - if (codeFile) { - return codeFile.code; - } - - const instrumenter = new Istanbul.Instrumenter({ - esModules: true, - codeGenerationOptions: { - sourceMap: filename, - sourceMapWithCode: true, - }, - }); - let instrumentedCode = instrumenter.instrumentSync(code, filename); - const match = code.match(inlineSourceMapRe); - - if (match) { - const sourceMapGenerator = instrumenter.sourceMap; - // Fix source maps for exception reporting (since the exceptions happen in the instrumented - // code. - const sourceMapJson = JSON.parse(Buffer.from(match[1], 'base64').toString()); - const consumer = new SourceMapConsumer(sourceMapJson); - sourceMapGenerator.applySourceMap(consumer, filename); - - instrumentedCode = instrumentedCode.replace(inlineSourceMapRe, '') - + '//# sourceMappingURL=data:application/json;base64,' - + new Buffer(sourceMapGenerator.toString()).toString('base64'); - - // Keep the consumer from the original source map, because the reports from Istanbul (not - // Constantinople) are already mapped against the code. - codeMap.set(filename, { code: instrumentedCode, map: consumer }); - } - - return instrumentedCode; -}; diff --git a/lib/packages.ts b/lib/packages.ts deleted file mode 100644 index ef9b82589ca7..000000000000 --- a/lib/packages.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable-next-line:no-implicit-dependencies -import { JsonObject } from '@angular-devkit/core'; -import { execSync } from 'child_process'; -import * as crypto from 'crypto'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as ts from 'typescript'; - -const glob = require('glob'); -const distRoot = path.join(__dirname, '../dist'); -const { packages: monorepoPackages } = require('../.monorepo.json'); - - -export interface PackageInfo { - name: string; - root: string; - bin: { [name: string]: string}; - relative: string; - main: string; - dist: string; - build: string; - tar: string; - private: boolean; - packageJson: JsonObject; - dependencies: string[]; - - snapshot: boolean; - snapshotRepo: string; - snapshotHash: string; - - dirty: boolean; - hash: string; - version: string; -} -export type PackageMap = { [name: string]: PackageInfo }; - - -const hashCache: {[name: string]: string | null} = {}; -function _getHashOf(pkg: PackageInfo): string { - if (!(pkg.name in hashCache)) { - hashCache[pkg.name] = null; - const md5Stream = crypto.createHash('md5'); - - // Update the stream with all files content. - const files: string[] = glob.sync(path.join(pkg.root, '**'), { nodir: true }); - files.forEach(filePath => { - md5Stream.write(`\0${filePath}\0`); - md5Stream.write(fs.readFileSync(filePath)); - }); - // Update the stream with all versions of upstream dependencies. - pkg.dependencies.forEach(depName => { - md5Stream.write(`\0${depName}\0${_getHashOf(packages[depName])}\0`); - }); - - md5Stream.end(); - - hashCache[pkg.name] = (md5Stream.read() as Buffer).toString('hex'); - } - - const value = hashCache[pkg.name]; - if (!value) { - // Protect against circular dependency. - throw new Error('Circular dependency detected between the following packages: ' - + Object.keys(hashCache).filter(key => hashCache[key] == null).join(', ')); - } - - return value; -} - - -function loadPackageJson(p: string) { - const root = require('../package.json'); - const pkg = require(p); - - for (const key of Object.keys(root)) { - switch (key) { - // Keep the following keys from the package.json of the package itself. - case 'bin': - case 'description': - case 'dependencies': - case 'name': - case 'main': - case 'peerDependencies': - case 'optionalDependencies': - case 'typings': - case 'version': - case 'private': - case 'workspaces': - case 'resolutions': - continue; - - // Remove the following keys from the package.json. - case 'devDependencies': - case 'scripts': - delete pkg[key]; - continue; - - // Merge the following keys with the root package.json. - case 'keywords': - const a = pkg[key] || []; - const b = Object.keys( - root[key].concat(a).reduce((acc: {[k: string]: boolean}, curr: string) => { - acc[curr] = true; - - return acc; - }, {})); - pkg[key] = b; - break; - - // Overwrite engines to a common default. - case 'engines': - pkg['engines'] = { - 'node': '>= 8.9.0', - 'npm': '>= 5.5.1', - }; - break; - - // Overwrite the package's key with to root one. - default: - pkg[key] = root[key]; - } - } - - return pkg; -} - - -function _findAllPackageJson(dir: string, exclude: RegExp): string[] { - const result: string[] = []; - fs.readdirSync(dir) - .forEach(fileName => { - const p = path.join(dir, fileName); - - if (exclude.test(p)) { - return; - } else if (/[\/\\]node_modules[\/\\]/.test(p)) { - return; - } else if (fileName == 'package.json') { - result.push(p); - } else if (fs.statSync(p).isDirectory() && fileName != 'node_modules') { - result.push(..._findAllPackageJson(p, exclude)); - } - }); - - return result; -} - - -const tsConfigPath = path.join(__dirname, '../tsconfig.json'); -const tsConfig = ts.readConfigFile(tsConfigPath, ts.sys.readFile); -const pattern = '^(' - + (tsConfig.config.exclude as string[]) - .map(ex => path.join(path.dirname(tsConfigPath), ex)) - .map(ex => '(' - + ex - .replace(/[\-\[\]{}()+?./\\^$|]/g, '\\$&') - .replace(/(\\\\|\\\/)\*\*/g, '((\/|\\\\).+?)?') - .replace(/\*/g, '[^/\\\\]*') - + ')') - .join('|') - + ')($|/|\\\\)'; -const excludeRe = new RegExp(pattern); - -// Find all the package.json that aren't excluded from tsconfig. -const packageJsonPaths = _findAllPackageJson(path.join(__dirname, '..'), excludeRe) - // Remove the root package.json. - .filter(p => p != path.join(__dirname, '../package.json')); - - -let gitShaCache: string; -function _getSnapshotHash(_pkg: PackageInfo): string { - if (!gitShaCache) { - gitShaCache = execSync('git log --format=%h -n1').toString().trim(); - } - - return gitShaCache; -} - - -// All the supported packages. Go through the packages directory and create a map of -// name => PackageInfo. This map is partial as it lacks some information that requires the -// map itself to finish building. -export const packages: PackageMap = - packageJsonPaths - .map(pkgPath => ({ root: path.dirname(pkgPath) })) - .reduce((packages: PackageMap, pkg) => { - const pkgRoot = pkg.root; - const packageJson = loadPackageJson(path.join(pkgRoot, 'package.json')); - const name = packageJson['name']; - if (!name) { - // Only build the entry if there's a package name. - return packages; - } - if (!(name in monorepoPackages)) { - throw new Error( - `Package ${name} found in ${JSON.stringify(pkg.root)}, not found in .monorepo.json.`, - ); - } - - const bin: {[name: string]: string} = {}; - Object.keys(packageJson['bin'] || {}).forEach(binName => { - let p = path.resolve(pkg.root, packageJson['bin'][binName]); - if (!fs.existsSync(p)) { - p = p.replace(/\.js$/, '.ts'); - } - bin[binName] = p; - }); - - packages[name] = { - build: path.join(distRoot, pkgRoot.substr(path.dirname(__dirname).length)), - dist: path.join(distRoot, name), - root: pkgRoot, - relative: path.relative(path.dirname(__dirname), pkgRoot), - main: path.resolve(pkgRoot, 'src/index.ts'), - private: packageJson.private, - // yarn doesn't take kindly to @ in tgz filenames - // https://github.com/yarnpkg/yarn/issues/6339 - tar: path.join(distRoot, name.replace(/\/|@/g, '_') + '.tgz'), - bin, - name, - packageJson, - - snapshot: !!monorepoPackages[name].snapshotRepo, - snapshotRepo: monorepoPackages[name].snapshotRepo, - get snapshotHash() { - return _getSnapshotHash(this); - }, - - dependencies: [], - hash: '', - dirty: false, - version: monorepoPackages[name] && monorepoPackages[name].version || '0.0.0', - }; - - return packages; - }, {}); - - -// Update with dependencies. -for (const pkgName of Object.keys(packages)) { - const pkg = packages[pkgName]; - const pkgJson = require(path.join(pkg.root, 'package.json')); - pkg.dependencies = Object.keys(packages).filter(name => { - return name in (pkgJson.dependencies || {}) - || name in (pkgJson.devDependencies || {}); - }); -} - - -// Update the hash values of each. -for (const pkgName of Object.keys(packages)) { - packages[pkgName].hash = _getHashOf(packages[pkgName]); - if (!monorepoPackages[pkgName] || packages[pkgName].hash != monorepoPackages[pkgName].hash) { - packages[pkgName].dirty = true; - } -} diff --git a/modules/testing/builder/BUILD.bazel b/modules/testing/builder/BUILD.bazel new file mode 100644 index 000000000000..45c39358c29c --- /dev/null +++ b/modules/testing/builder/BUILD.bazel @@ -0,0 +1,55 @@ +load("@npm//:defs.bzl", "npm_link_all_packages") +load("//tools:defaults.bzl", "jasmine_test", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +npm_link_all_packages() + +ts_project( + name = "builder", + testonly = True, + srcs = glob( + include = [ + "src/**/*.ts", + ], + exclude = [ + "src/**/*_spec.ts", + ], + ), + data = [ + # Needed at runtime by some builder tests relying on SSR being + # resolvable in the test project. + ":node_modules/@angular/ssr", + ":node_modules/browser-sync", + ":node_modules/istanbul-lib-instrument", + ":node_modules/jsdom", + ":node_modules/ng-packagr", + ":node_modules/vitest", + ":node_modules/@vitest/coverage-v8", + ] + glob(["projects/**/*"]), + deps = [ + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + ":node_modules/rxjs", + "//:node_modules/@types/node", + ], +) + +ts_project( + name = "unit_test_lib", + testonly = True, + srcs = glob( + include = [ + "src/**/*_spec.ts", + ], + ), + deps = [ + ":builder", + ":node_modules/@angular-devkit/architect", + ], +) + +jasmine_test( + name = "test", + data = [":unit_test_lib"], +) diff --git a/modules/testing/builder/package.json b/modules/testing/builder/package.json new file mode 100644 index 000000000000..149a97369205 --- /dev/null +++ b/modules/testing/builder/package.json @@ -0,0 +1,15 @@ +{ + "devDependencies": { + "@angular-devkit/architect": "workspace:*", + "@angular-devkit/build-angular": "workspace:*", + "@angular-devkit/core": "workspace:*", + "@angular/ssr": "workspace:*", + "@vitest/coverage-v8": "5.0.5", + "browser-sync": "3.0.4", + "istanbul-lib-instrument": "6.0.3", + "jsdom": "30.1.3", + "ng-packagr": "22.3.0-next.2", + "rxjs": "7.8.2", + "vitest": "5.0.5" + } +} diff --git a/modules/testing/builder/projects/hello-world-app/.editorconfig b/modules/testing/builder/projects/hello-world-app/.editorconfig new file mode 100644 index 000000000000..e89330a618c1 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/.editorconfig @@ -0,0 +1,13 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/tests/angular_devkit/build_angular/hello-world-app/.gitignore b/modules/testing/builder/projects/hello-world-app/.gitignore similarity index 100% rename from tests/angular_devkit/build_angular/hello-world-app/.gitignore rename to modules/testing/builder/projects/hello-world-app/.gitignore diff --git a/modules/testing/builder/projects/hello-world-app/README.md b/modules/testing/builder/projects/hello-world-app/README.md new file mode 100644 index 000000000000..c2795c8bcc94 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/README.md @@ -0,0 +1,27 @@ +# HelloWorldApp + +This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 1.7.0-beta.1. + +## Development server + +Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. + +## Code scaffolding + +Run `ng generate component component-name` to generate a new component. You can also use `ng generate --help` to see all the available schematics you can generate. + +## Build + +Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `-prod` flag for a production build. + +## Running unit tests + +Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). + +## Running end-to-end tests + +Run `ng e2e` to setup and run the end-to-end tests. + +## Further help + +To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/main/README.md). diff --git a/modules/testing/builder/projects/hello-world-app/angular.json b/modules/testing/builder/projects/hello-world-app/angular.json new file mode 100644 index 000000000000..9ab612b8b2c5 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/angular.json @@ -0,0 +1,177 @@ +{ + "$schema": "../../../../packages/angular_devkit/core/src/workspace/workspace-schema.json", + "version": 1, + "newProjectRoot": "./projects", + "cli": { + "cache": { + "enabled": false + } + }, + "schematics": {}, + "projects": { + "app": { + "root": "src", + "sourceRoot": "src", + "projectType": "application", + "prefix": "app", + "schematics": {}, + "targets": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "progress": false, + "sourceMap": false, + "aot": false, + "vendorChunk": true, + "buildOptimizer": false, + "optimization": false, + "extractLicenses": false, + "namedChunks": true, + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.css" + ], + "scripts": [] + }, + "configurations": { + "production": { + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true + }, + "inline-critical-css": { + "optimization": { + "styles": { + "minify": true, + "inlineCritical": true + }, + "scripts": true, + "fonts": true + } + }, + "sw": { + "ngswConfigPath": "src/ngsw-config.json", + "serviceWorker": true + } + } + }, + "server": { + "builder": "@angular-devkit/build-angular:server", + "options": { + "outputPath": "dist-server", + "main": "src/main.server.ts", + "tsConfig": "src/tsconfig.server.json", + "progress": false, + "sourceMap": true, + "optimization": false, + "extractLicenses": false + }, + "configurations": { + "production": { + "outputHashing": "media", + "sourceMap": false, + "optimization": true + } + } + }, + "app-shell": { + "builder": "@angular-devkit/build-angular:app-shell", + "options": { + "browserTarget": "app:build", + "serverTarget": "app:server" + }, + "configurations": { + "production": { + "browserTarget": "app:build:production" + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "buildTarget": "app:build", + "watch": false + }, + "configurations": { + "production": { + "buildTarget": "app:build:production" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "buildTarget": "app:build", + "progress": false, + "outputPath": "src" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "karma.conf.js", + "browsers": "ChromeHeadlessCI", + "progress": false, + "watch": false, + "styles": [ + { + "input": "src/styles.css" + } + ], + "scripts": [], + "assets": [ + "src/favicon.ico", + "src/assets" + ] + } + }, + "serve-ssr": { + "builder": "@angular-devkit/build-angular:ssr-dev-server", + "options": { + "browserTarget": "app:build", + "serverTarget": "app:server", + "watch": false, + "progress": false + }, + "configurations": { + "production": { + "browserTarget": "app:build:production", + "serverTarget": "app:server:production" + } + } + }, + "prerender": { + "builder": "@angular-devkit/build-angular:prerender", + "options": { + "browserTarget": "app:build", + "serverTarget": "app:server" + }, + "configurations": { + "production": { + "browserTarget": "app:build:production", + "serverTarget": "app:server:production" + } + } + } + } + } + } +} diff --git a/modules/testing/builder/projects/hello-world-app/karma.conf.js b/modules/testing/builder/projects/hello-world-app/karma.conf.js new file mode 100644 index 000000000000..d3206bd4cf87 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/karma.conf.js @@ -0,0 +1,56 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +const path = require('path'); + +// Resolve from relative paths to absolute paths within the bazel runfiles tree +// so subprocesses spawned in a different working directory can still find them. +process.env.CHROME_BIN = process.env.CHROME_BIN ? path.resolve(path.join(process.cwd(), '..', '..', '..'), process.env.CHROME_BIN) : require('puppeteer').executablePath(); + +module.exports = function(config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage'), + require('@angular-devkit/build-angular/plugins/karma'), + ], + jasmineHtmlReporter: { + suppressAll: true // removes the duplicated traces + }, + coverageReporter: { + dir: path.join(__dirname, './coverage'), + subdir: '.', + reporters: [ + {type: 'lcov'}, + ], + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['Chrome'], + customLaunchers: { + ChromeHeadlessCI: { + base: 'ChromeHeadless', + flags: [ + '--disable-gpu', + '--no-sandbox' + ], + }, + }, + singleRun: false, + }); +}; diff --git a/tests/angular_devkit/build_angular/hello-world-app/src/app/app.component.css b/modules/testing/builder/projects/hello-world-app/src/app/app.component.css similarity index 100% rename from tests/angular_devkit/build_angular/hello-world-app/src/app/app.component.css rename to modules/testing/builder/projects/hello-world-app/src/app/app.component.css diff --git a/modules/testing/builder/projects/hello-world-app/src/app/app.component.html b/modules/testing/builder/projects/hello-world-app/src/app/app.component.html new file mode 100644 index 000000000000..fe14e2bc127c --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/app/app.component.html @@ -0,0 +1,22 @@ + +
+

+ Welcome to {{ title }}! +

+ Angular Logo +
+

Here are some links to help you start:

+
+ +

i18n test

+ diff --git a/modules/testing/builder/projects/hello-world-app/src/app/app.component.spec.ts b/modules/testing/builder/projects/hello-world-app/src/app/app.component.spec.ts new file mode 100644 index 000000000000..37e8de10ba72 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/app/app.component.spec.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { TestBed } from '@angular/core/testing'; +import { AppComponent } from './app.component'; +describe('AppComponent', () => { + beforeEach(() => TestBed.configureTestingModule({ + declarations: [AppComponent], + })); + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.debugElement.componentInstance; + expect(app).toBeTruthy(); + }); + it(`should have as title 'app'`, () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.debugElement.componentInstance; + expect(app.title).toEqual('app'); + }); + it('should render title in a h1 tag', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.debugElement.nativeElement; + expect(compiled.querySelector('h1').textContent).toContain('Welcome to app!'); + }); +}); diff --git a/modules/testing/builder/projects/hello-world-app/src/app/app.component.ts b/modules/testing/builder/projects/hello-world-app/src/app/app.component.ts new file mode 100644 index 000000000000..93a041e8aec7 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/app/app.component.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-root', + standalone: false, + templateUrl: './app.component.html', + styleUrls: ['./app.component.css'], +}) +export class AppComponent { + title = 'app'; +} diff --git a/modules/testing/builder/projects/hello-world-app/src/app/app.module.server.ts b/modules/testing/builder/projects/hello-world-app/src/app/app.module.server.ts new file mode 100644 index 000000000000..76fd51151562 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/app/app.module.server.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { NgModule } from '@angular/core'; +import { ServerModule } from '@angular/platform-server'; + +import { AppModule } from './app.module'; +import { AppComponent } from './app.component'; + +@NgModule({ + imports: [ + AppModule, + ServerModule, + ], + bootstrap: [AppComponent], +}) +export class AppServerModule {} diff --git a/modules/testing/builder/projects/hello-world-app/src/app/app.module.ts b/modules/testing/builder/projects/hello-world-app/src/app/app.module.ts new file mode 100644 index 000000000000..d8060d5ac4c9 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/app/app.module.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BrowserModule } from '@angular/platform-browser'; +import { NgModule } from '@angular/core'; + + +import { AppComponent } from './app.component'; + + +@NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule + ], + providers: [], + bootstrap: [AppComponent] +}) +export class AppModule { } diff --git a/tests/angular_devkit/build_angular/hello-world-app/src/assets/.gitkeep b/modules/testing/builder/projects/hello-world-app/src/assets/.gitkeep similarity index 100% rename from tests/angular_devkit/build_angular/hello-world-app/src/assets/.gitkeep rename to modules/testing/builder/projects/hello-world-app/src/assets/.gitkeep diff --git a/packages/schematics/angular/application/files/src/favicon.ico b/modules/testing/builder/projects/hello-world-app/src/favicon.ico similarity index 100% rename from packages/schematics/angular/application/files/src/favicon.ico rename to modules/testing/builder/projects/hello-world-app/src/favicon.ico diff --git a/tests/angular_devkit/build_angular/hello-world-app/src/index.html b/modules/testing/builder/projects/hello-world-app/src/index.html similarity index 100% rename from tests/angular_devkit/build_angular/hello-world-app/src/index.html rename to modules/testing/builder/projects/hello-world-app/src/index.html diff --git a/tests/angular_devkit/build_angular/hello-world-app/src/src/locale/messages.xlf b/modules/testing/builder/projects/hello-world-app/src/locale/messages.xlf similarity index 100% rename from tests/angular_devkit/build_angular/hello-world-app/src/src/locale/messages.xlf rename to modules/testing/builder/projects/hello-world-app/src/locale/messages.xlf diff --git a/modules/testing/builder/projects/hello-world-app/src/main.server.ts b/modules/testing/builder/projects/hello-world-app/src/main.server.ts new file mode 100644 index 000000000000..0c26cf60e980 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/main.server.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { AppServerModule } from './app/app.module.server'; +export default AppServerModule; +export { AppServerModule }; diff --git a/modules/testing/builder/projects/hello-world-app/src/main.ts b/modules/testing/builder/projects/hello-world-app/src/main.ts new file mode 100644 index 000000000000..d975b48d7808 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/main.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { platformBrowser } from '@angular/platform-browser'; + +import { AppModule } from './app/app.module'; + +platformBrowser() + .bootstrapModule(AppModule) + .catch(err => console.log(err)); diff --git a/modules/testing/builder/projects/hello-world-app/src/ngsw-config.json b/modules/testing/builder/projects/hello-world-app/src/ngsw-config.json new file mode 100644 index 000000000000..789b03bdf7ce --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/ngsw-config.json @@ -0,0 +1,30 @@ +{ + "$schema": "../node_modules/@angular/service-worker/config/schema.json", + "index": "/index.html", + "assetGroups": [ + { + "name": "app", + "installMode": "prefetch", + "resources": { + "files": [ + "/favicon.ico", + "/index.html", + "/manifest.webmanifest", + "/*.css", + "/*.js" + ] + } + }, + { + "name": "assets", + "installMode": "lazy", + "updateMode": "prefetch", + "resources": { + "files": [ + "/assets/**", + "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" + ] + } + } + ] +} diff --git a/modules/testing/builder/projects/hello-world-app/src/polyfills.ts b/modules/testing/builder/projects/hello-world-app/src/polyfills.ts new file mode 100644 index 000000000000..eb77e392ee4e --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/polyfills.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * This file includes polyfills needed by Angular and is loaded before the app. + * You can add your own extra polyfills to this file. + * + * This file is divided into 2 sections: + * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. + * 2. Application imports. Files imported after ZoneJS that should be loaded before your main + * file. + * + * The current setup is for so-called "evergreen" browsers; the last versions of browsers that + * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), + * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. + * + * Learn more in https://angular.dev/reference/versions#browser-support + */ + +/*************************************************************************************************** + * BROWSER POLYFILLS + */ + +/** IE10 and IE11 requires the following for NgClass support on SVG elements */ +// import 'classlist.js'; // Run `npm install --save classlist.js`. + +/** + * Web Animations `@angular/platform-browser/animations` + * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. + * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). + */ +// import 'web-animations-js'; // Run `npm install --save web-animations-js`. + +/** + * By default, zone.js will patch all possible macroTask and DomEvents + * user can disable parts of macroTask/DomEvents patch by setting following flags + * because those flags need to be set before `zone.js` being loaded, and webpack + * will put import in the top of bundle, so user need to create a separate file + * in this directory (for example: zone-flags.ts), and put the following flags + * into that file, and then add the following code before importing zone.js. + * import './zone-flags.ts'; + * + * The flags allowed in zone-flags.ts are listed here. + * + * The following flags will work for all browsers. + * + * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame + * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick + * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames + * + * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js + * with the following flag, it will bypass `zone.js` patch for IE/Edge + * + * (window as any).__Zone_enable_cross_context_check = true; + * + */ + +/*************************************************************************************************** + * Zone JS is required by default for Angular itself. + */ +import 'zone.js'; // Included with Angular CLI. + +/*************************************************************************************************** + * APPLICATION IMPORTS + */ diff --git a/tests/angular_devkit/build_angular/hello-world-app/src/spectrum.png b/modules/testing/builder/projects/hello-world-app/src/spectrum.png similarity index 100% rename from tests/angular_devkit/build_angular/hello-world-app/src/spectrum.png rename to modules/testing/builder/projects/hello-world-app/src/spectrum.png diff --git a/tests/angular_devkit/build_angular/hello-world-app/src/styles.css b/modules/testing/builder/projects/hello-world-app/src/styles.css similarity index 100% rename from tests/angular_devkit/build_angular/hello-world-app/src/styles.css rename to modules/testing/builder/projects/hello-world-app/src/styles.css diff --git a/modules/testing/builder/projects/hello-world-app/src/tsconfig.app.json b/modules/testing/builder/projects/hello-world-app/src/tsconfig.app.json new file mode 100644 index 000000000000..a76843b689fb --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/tsconfig.app.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/app", + "types": ["@angular/localize"] + }, + "files": [ + "main.ts", + "polyfills.ts" + ], + "include": [ + "**/*.d.ts" + ] +} diff --git a/modules/testing/builder/projects/hello-world-app/src/tsconfig.server.json b/modules/testing/builder/projects/hello-world-app/src/tsconfig.server.json new file mode 100644 index 000000000000..3fd4df49c45d --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/tsconfig.server.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "outDir": "../dist-server", + "types": ["@angular/localize", "node", "@types/express"] + }, + "files": [ + "main.server.ts" + ] +} diff --git a/modules/testing/builder/projects/hello-world-app/src/tsconfig.spec.json b/modules/testing/builder/projects/hello-world-app/src/tsconfig.spec.json new file mode 100644 index 000000000000..a42459f71db5 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/spec", + "types": [ + "jasmine", + "@angular/localize" + ] + }, + "files": [ + "polyfills.ts" + ], + "include": [ + "**/*.spec.ts", + "**/*.d.ts" + ] +} diff --git a/modules/testing/builder/projects/hello-world-app/src/typings.d.ts b/modules/testing/builder/projects/hello-world-app/src/typings.d.ts new file mode 100644 index 000000000000..161056781dc0 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/typings.d.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 + */ + +/* SystemJS module definition */ +declare var module: NodeModule; +interface NodeModule { + id: string; +} diff --git a/modules/testing/builder/projects/hello-world-app/tsconfig.json b/modules/testing/builder/projects/hello-world-app/tsconfig.json new file mode 100644 index 000000000000..4f9890cb8e7d --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "sourceMap": true, + "declaration": false, + "moduleResolution": "bundler", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "skipLibCheck": true, + "target": "es2022", + "module": "es2022", + "lib": [ + "es2022", + "dom" + ] + }, + "angularCompilerOptions": { + "enableIvy": true, + "disableTypeScriptVersionCheck": true, + "strictTemplates": true + } +} diff --git a/modules/testing/builder/projects/hello-world-lib/.gitignore b/modules/testing/builder/projects/hello-world-lib/.gitignore new file mode 100644 index 000000000000..e0406551d1ea --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/.gitignore @@ -0,0 +1,43 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# compiled output +/dist +/tmp +/out-tsc + +# dependencies +/node_modules + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +/.angular/cache +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +testem.log +/typings + +# e2e +/e2e/*.js +/e2e/*.map + +# System Files +.DS_Store +Thumbs.db diff --git a/modules/testing/builder/projects/hello-world-lib/angular.json b/modules/testing/builder/projects/hello-world-lib/angular.json new file mode 100644 index 000000000000..9925e3d08f14 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/angular.json @@ -0,0 +1,29 @@ +{ + "$schema": "../../../../packages/angular_devkit/core/src/workspace/workspace-schema.json", + "version": 1, + "cli": { + "cache": { + "enabled": false + } + }, + "projects": { + "lib": { + "root": "projects/lib", + "projectType": "library", + "targets": { + "build": { + "builder": "@angular-devkit/build-angular:ng-packagr", + "options": { + "project": "projects/lib/ng-package.json", + "tsConfig": "projects/lib/tsconfig.lib.json" + }, + "configurations": { + "production": { + "tsConfig": "projects/lib/tsconfig.lib.prod.json" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/karma.conf.js b/modules/testing/builder/projects/hello-world-lib/projects/lib/karma.conf.js new file mode 100644 index 000000000000..5573d103b26e --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/karma.conf.js @@ -0,0 +1,48 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage-istanbul-reporter'), + require('@angular-devkit/build-angular/plugins/karma') + ], + jasmineHtmlReporter: { + suppressAll: true // removes the duplicated traces + }, + coverageIstanbulReporter: { + dir: require('path').join(__dirname, 'coverage'), + reports: ['html', 'lcovonly'], + fixWebpackSourcePaths: true + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['Chrome'], + customLaunchers: { + ChromeHeadlessCI: { + base: 'ChromeHeadless', + flags: [ + '--disable-gpu', + '--no-sandbox' + ], + } + }, + singleRun: false + }); +}; diff --git a/tests/angular_devkit/build_ng_packagr/ng-packaged/projects/lib/ng-package.json b/modules/testing/builder/projects/hello-world-lib/projects/lib/ng-package.json similarity index 77% rename from tests/angular_devkit/build_ng_packagr/ng-packaged/projects/lib/ng-package.json rename to modules/testing/builder/projects/hello-world-lib/projects/lib/ng-package.json index 36fcf49d9c1b..cce46c5a8b7b 100644 --- a/tests/angular_devkit/build_ng_packagr/ng-packaged/projects/lib/ng-package.json +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/ng-package.json @@ -2,6 +2,6 @@ "$schema": "../../../../../../node_modules/ng-packagr/ng-package.schema.json", "dest": "../../dist/lib", "lib": { - "entryFile": "src/public_api.ts" + "entryFile": "src/public-api.ts" } } \ No newline at end of file diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/package.json b/modules/testing/builder/projects/hello-world-lib/projects/lib/package.json new file mode 100644 index 000000000000..c8105b2c16b4 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/package.json @@ -0,0 +1,8 @@ +{ + "name": "lib", + "version": "0.0.1", + "peerDependencies": { + "@angular/common": "^22.2.0-next", + "@angular/core": "^22.2.0-next" + } +} \ No newline at end of file diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.component.spec.ts b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.component.spec.ts new file mode 100644 index 000000000000..dbdec32d37c2 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.component.spec.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LibComponent } from './lib.component'; + +describe('LibComponent', () => { + let component: LibComponent; + let fixture: ComponentFixture; + + beforeEach(() => TestBed.configureTestingModule({ + declarations: [LibComponent] + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LibComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.component.ts b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.component.ts new file mode 100644 index 000000000000..3e1ee2aebd2b --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.component.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Component } from '@angular/core'; + +@Component({ + selector: 'lib', + template: ` +

+ lib works! +

+ `, + styles: [] +}) +export class LibComponent { + +} diff --git a/tests/angular_devkit/build_ng_packagr/ng-packaged/projects/lib/src/lib/lib.service.spec.ts b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.service.spec.ts similarity index 81% rename from tests/angular_devkit/build_ng_packagr/ng-packaged/projects/lib/src/lib/lib.service.spec.ts rename to modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.service.spec.ts index a808deee36e3..1bc021e00abb 100644 --- a/tests/angular_devkit/build_ng_packagr/ng-packaged/projects/lib/src/lib/lib.service.spec.ts +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.service.spec.ts @@ -1,10 +1,11 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { TestBed, inject } from '@angular/core/testing'; import { LibService } from './lib.service'; diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.service.ts b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.service.ts new file mode 100644 index 000000000000..20c9f8075e86 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.service.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Injectable } from '@angular/core'; + +@Injectable() +export class LibService { + + testEs2016() { + return ['foo', 'bar'].includes('foo'); + } + +} diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/src/public-api.ts b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/public-api.ts new file mode 100644 index 000000000000..b641e95d8900 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/public-api.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/* + * Public API Surface of lib + */ + +export * from './lib/lib.service'; +export * from './lib/lib.component'; diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/tsconfig.lib.json b/modules/testing/builder/projects/hello-world-lib/projects/lib/tsconfig.lib.json new file mode 100644 index 000000000000..23e851ac9c8b --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "**/*.spec.ts" + ] +} diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/tsconfig.lib.prod.json b/modules/testing/builder/projects/hello-world-lib/projects/lib/tsconfig.lib.prod.json new file mode 100644 index 000000000000..2a2faa884cf3 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/tsconfig.lib.prod.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/tsconfig.spec.json b/modules/testing/builder/projects/hello-world-lib/projects/lib/tsconfig.spec.json new file mode 100644 index 000000000000..eae9b9ae9c29 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/tsconfig.spec.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "**/*.spec.ts", + "**/*.d.ts" + ] +} diff --git a/modules/testing/builder/projects/hello-world-lib/tsconfig.json b/modules/testing/builder/projects/hello-world-lib/tsconfig.json new file mode 100644 index 000000000000..8ccae14cd594 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "sourceMap": true, + "declaration": false, + "moduleResolution": "bundler", + "experimentalDecorators": true, + "target": "es2015", + "module": "es2022", + "useDefineForClassFields": false, + "lib": [ + "es2022", + "dom" + ] + }, + "angularCompilerOptions": { + "enableIvy": true, + "disableTypeScriptVersionCheck": true + } +} diff --git a/modules/testing/builder/src/builder-harness.ts b/modules/testing/builder/src/builder-harness.ts new file mode 100644 index 000000000000..570939cecd7f --- /dev/null +++ b/modules/testing/builder/src/builder-harness.ts @@ -0,0 +1,550 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use 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 import/no-extraneous-dependencies */ + +import { + BuilderContext, + BuilderHandlerFn, + BuilderInfo, + BuilderOutput, + BuilderOutputLike, + BuilderProgressReport, + BuilderRun, + ScheduleOptions, + Target, + fromAsyncIterable, + isBuilderOutput, +} from '@angular-devkit/architect'; +import { WorkspaceHost } from '@angular-devkit/architect/node'; +import { TestProjectHost } from '@angular-devkit/architect/testing'; +import { getSystemPath, json, logging } from '@angular-devkit/core'; +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { + Observable, + Subject, + catchError, + finalize, + firstValueFrom, + lastValueFrom, + map, + mergeMap, + from as observableFrom, + of as observableOf, + shareReplay, +} from 'rxjs'; +import { BuilderWatcherFactory, WatcherNotifier } from './file-watching'; + +export interface BuilderHarnessExecutionResult { + result?: T; + error?: Error; + logs: readonly logging.LogEntry[]; +} + +export interface BuilderHarnessExecutionOptions { + configuration: string; + outputLogsOnFailure: boolean; + outputLogsOnException: boolean; + useNativeFileWatching: boolean; + signal: AbortSignal; + additionalExecuteArguments: unknown[]; +} + +interface BuilderHandlerFnWithVarArgs extends BuilderHandlerFn { + (input: T, context: BuilderContext, ...args: unknown[]): BuilderOutputLike; +} + +/** + * The default set of fields provided to all builders executed via the BuilderHarness. + * `root` and `sourceRoot` are required for most Angular builders to function. + * `cli.cache.enabled` set to false provides improved test isolation guarantees by disabling + * the Webpack caching. + */ +const DEFAULT_PROJECT_METADATA = { + root: '.', + sourceRoot: 'src', + cli: { + cache: { + enabled: false, + }, + }, +}; + +export class BuilderHarness { + private readonly builderInfo: BuilderInfo; + private schemaRegistry = new json.schema.CoreSchemaRegistry(); + private projectName = 'test'; + private projectMetadata: Record = DEFAULT_PROJECT_METADATA; + private targetName?: string; + private options = new Map(); + private builderTargets = new Map< + string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { handler: BuilderHandlerFn; info: BuilderInfo; options: json.JsonObject } + >(); + private watcherNotifier?: WatcherNotifier; + + constructor( + private readonly builderHandler: BuilderHandlerFn, + private readonly host: TestProjectHost, + builderInfo?: Partial, + ) { + // Generate default pseudo builder info for test purposes + this.builderInfo = { + builderName: builderHandler.name, + description: '', + optionSchema: true, + ...builderInfo, + }; + + if (builderInfo?.builderName?.startsWith('@angular/build:')) { + this.schemaRegistry.addPostTransform(json.schema.transforms.addUndefinedObjectDefaults); + } else { + this.schemaRegistry.addPostTransform(json.schema.transforms.addUndefinedDefaults); + } + } + + resolvePath(path: string): string { + return join(getSystemPath(this.host.root()), path); + } + + resetProjectMetadata(): void { + this.projectMetadata = DEFAULT_PROJECT_METADATA; + } + + useProject(name: string, metadata: Record = {}): this { + if (!name) { + throw new Error('Project name cannot be an empty string.'); + } + + this.projectName = name; + this.projectMetadata = metadata; + + return this; + } + + useTarget(name: string, baseOptions: T): this { + if (!name) { + throw new Error('Target name cannot be an empty string.'); + } + + this.targetName = name; + this.options.set(null, baseOptions); + + return this; + } + + withConfiguration(configuration: string, options: T): this { + this.options.set(configuration, options); + + return this; + } + + withBuilderTarget( + target: string, + handler: BuilderHandlerFn, + options?: O, + info?: Partial, + ): this { + this.builderTargets.set(target, { + handler, + options: options || {}, + info: { builderName: handler.name, description: '', optionSchema: true, ...info }, + }); + + return this; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + modifyTarget( + targetName: string, + modifier: (options: O) => O | void, + ): this { + const target = this.builderTargets.get(targetName); + if (!target) { + throw new Error(`Target "${targetName}" not found.`); + } + + const newOptions = modifier(target.options as O); + if (newOptions) { + target.options = newOptions as json.JsonObject; + } + + return this; + } + + execute( + options: Partial = {}, + ): Observable { + const { + configuration, + outputLogsOnException = true, + outputLogsOnFailure = true, + useNativeFileWatching = false, + } = options; + + const targetOptions = { + ...this.options.get(null), + ...((configuration && this.options.get(configuration)) ?? {}), + }; + + if (!useNativeFileWatching) { + if (this.watcherNotifier) { + throw new Error('Only one harness execution at a time is supported.'); + } + this.watcherNotifier = new WatcherNotifier(); + } + + const contextHost: ContextHost = { + findBuilderByTarget: async (project, target) => { + this.validateProjectName(project); + if (target === this.targetName) { + return { + info: this.builderInfo, + handler: this.builderHandler as BuilderHandlerFn, + }; + } + + const builderTarget = this.builderTargets.get(target); + if (builderTarget) { + return { info: builderTarget.info, handler: builderTarget.handler }; + } + + throw new Error('Project target does not exist.'); + }, + async getBuilderName(project, target) { + return (await this.findBuilderByTarget(project, target)).info.builderName; + }, + getMetadata: async (project) => { + this.validateProjectName(project); + + return this.projectMetadata as json.JsonObject; + }, + getOptions: async (project, target, configuration) => { + this.validateProjectName(project); + if (target === this.targetName) { + return (this.options.get(configuration ?? null) ?? {}) as json.JsonObject; + } else if (configuration !== undefined) { + // Harness builder targets currently do not support configurations + return {}; + } else { + return this.builderTargets.get(target)?.options || {}; + } + }, + hasTarget: async (project, target) => { + this.validateProjectName(project); + + return this.targetName === target || this.builderTargets.has(target); + }, + getDefaultConfigurationName: async (_project, _target) => { + return undefined; + }, + validate: async (options, builderName) => { + let schema; + if (builderName === this.builderInfo.builderName) { + schema = this.builderInfo.optionSchema; + } else { + for (const [, value] of this.builderTargets) { + if (value.info.builderName === builderName) { + schema = value.info.optionSchema; + break; + } + } + } + + const validator = await this.schemaRegistry.compile(schema ?? true); + const { data } = await validator(options); + + return data as json.JsonObject; + }, + }; + const context = new HarnessBuilderContext( + this.builderInfo, + this.resolvePath('.'), + contextHost, + options.signal, + useNativeFileWatching ? undefined : this.watcherNotifier, + ); + if (this.targetName !== undefined) { + context.target = { + project: this.projectName, + target: this.targetName, + configuration: configuration as string, + }; + } + + const logs: logging.LogEntry[] = []; + const logger$ = context.logger.subscribe((e) => logs.push(e)); + + return observableFrom(this.schemaRegistry.compile(this.builderInfo.optionSchema)).pipe( + mergeMap((validator) => validator(targetOptions)), + map((validationResult) => validationResult.data), + mergeMap((data) => + convertBuilderOutputToObservable( + (this.builderHandler as BuilderHandlerFnWithVarArgs)( + data as T & json.JsonObject, + context, + ...(options.additionalExecuteArguments ?? []), + ), + ), + ), + map((buildResult) => ({ result: buildResult, error: undefined })), + catchError((error) => { + if (outputLogsOnException) { + // eslint-disable-next-line no-console + console.error(logs.map((entry) => entry.message).join('\n')); + // eslint-disable-next-line no-console + console.error(error); + } + + return observableOf({ result: undefined, error }); + }), + map(({ result, error }) => { + if (outputLogsOnFailure && result?.success === false && logs.length > 0) { + // eslint-disable-next-line no-console + console.error(logs.map((entry) => entry.message).join('\n')); + } + + // Capture current logs and clear for next + const currentLogs = logs.slice(); + logs.length = 0; + + return { result, error, logs: currentLogs }; + }), + finalize(() => { + this.watcherNotifier = undefined; + logger$.unsubscribe(); + + for (const teardown of context.teardowns) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + teardown(); + } + }), + ); + } + + async executeOnce( + options?: Partial, + ): Promise { + // Return the first result + return firstValueFrom(this.execute(options)); + } + + async appendToFile(path: string, content: string): Promise { + await this.writeFile(path, this.readFile(path).concat(content)); + } + + async writeFile(path: string, content: string | Buffer): Promise { + const fullPath = this.resolvePath(path); + + await fs.mkdir(dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, content, 'utf-8'); + + this.watcherNotifier?.notify([{ path: fullPath, type: 'modified' }]); + } + + async writeFiles(files: Record): Promise { + const watchEvents = this.watcherNotifier + ? ([] as { path: string; type: 'modified' | 'deleted' }[]) + : undefined; + + for (const [path, content] of Object.entries(files)) { + const fullPath = this.resolvePath(path); + + await fs.mkdir(dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, content, 'utf-8'); + + watchEvents?.push({ path: fullPath, type: 'modified' }); + } + + if (watchEvents) { + this.watcherNotifier?.notify(watchEvents); + } + } + + async removeFile(path: string): Promise { + const fullPath = this.resolvePath(path); + + await fs.unlink(fullPath); + + this.watcherNotifier?.notify([{ path: fullPath, type: 'deleted' }]); + } + + async modifyFile( + path: string, + modifier: (content: string) => string | Promise, + ): Promise { + const content = this.readFile(path); + await this.writeFile(path, await modifier(content)); + } + + hasFile(path: string): boolean { + const fullPath = this.resolvePath(path); + + return existsSync(fullPath); + } + + hasDirectory(path: string): boolean { + const fullPath = this.resolvePath(path); + + return statSync(fullPath, { throwIfNoEntry: false })?.isDirectory() ?? false; + } + + hasFileMatch(directory: string, pattern: RegExp): boolean { + const fullPath = this.resolvePath(directory); + + return readdirSync(fullPath).some((name) => pattern.test(name)); + } + + readFile(path: string): string { + const fullPath = this.resolvePath(path); + + return readFileSync(fullPath, 'utf-8'); + } + + private validateProjectName(name: string): void { + if (name !== this.projectName) { + throw new Error(`Project "${name}" does not exist.`); + } + } +} + +interface ContextHost extends WorkspaceHost { + findBuilderByTarget( + project: string, + target: string, + ): Promise<{ info: BuilderInfo; handler: BuilderHandlerFn }>; + validate(options: json.JsonObject, builderName: string): Promise; +} + +class HarnessBuilderContext implements BuilderContext { + id = Math.trunc(Math.random() * 1000000); + logger = new logging.Logger(`builder-harness-${this.id}`); + workspaceRoot: string; + currentDirectory: string; + target?: Target; + + teardowns: (() => Promise | void)[] = []; + + constructor( + public builder: BuilderInfo, + basePath: string, + private readonly contextHost: ContextHost, + public readonly signal: AbortSignal | undefined, + public readonly watcherFactory: BuilderWatcherFactory | undefined, + ) { + this.workspaceRoot = this.currentDirectory = basePath; + } + + addTeardown(teardown: () => Promise | void): void { + this.teardowns.push(teardown); + } + + async getBuilderNameForTarget(target: Target): Promise { + return this.contextHost.getBuilderName(target.project, target.target); + } + + async getProjectMetadata(targetOrName: Target | string): Promise { + const project = typeof targetOrName === 'string' ? targetOrName : targetOrName.project; + + return this.contextHost.getMetadata(project); + } + + async getTargetOptions(target: Target): Promise { + return this.contextHost.getOptions(target.project, target.target, target.configuration); + } + + // Unused by builders in this package + async scheduleBuilder( + builderName: string, + options?: json.JsonObject, + scheduleOptions?: ScheduleOptions, + ): Promise { + throw new Error('Not Implemented.'); + } + + async scheduleTarget( + target: Target, + overrides?: json.JsonObject, + scheduleOptions?: ScheduleOptions, + ): Promise { + const { info, handler } = await this.contextHost.findBuilderByTarget( + target.project, + target.target, + ); + const targetOptions = await this.validateOptions( + { + ...(await this.getTargetOptions(target)), + ...overrides, + }, + info.builderName, + ); + + const context = new HarnessBuilderContext( + info, + this.workspaceRoot, + this.contextHost, + this.signal, + this.watcherFactory, + ); + context.target = target; + context.logger = scheduleOptions?.logger || this.logger.createChild(''); + + const progressSubject = new Subject(); + const output = convertBuilderOutputToObservable(handler(targetOptions, context)); + + const run: BuilderRun = { + id: context.id, + info, + progress: progressSubject.asObservable(), + async stop() { + for (const teardown of context.teardowns) { + await teardown(); + } + progressSubject.complete(); + }, + output: output.pipe(shareReplay()), + get result() { + return firstValueFrom(this.output); + }, + get lastOutput() { + return lastValueFrom(this.output); + }, + }; + + return run; + } + + async validateOptions( + options: json.JsonObject, + builderName: string, + ): Promise { + return this.contextHost.validate(options, builderName) as unknown as T; + } + + // Unused report methods + reportRunning(): void {} + reportStatus(): void {} + reportProgress(): void {} +} + +function isAsyncIterable(obj: unknown): obj is AsyncIterable { + return !!obj && typeof (obj as AsyncIterable)[Symbol.asyncIterator] === 'function'; +} + +function convertBuilderOutputToObservable(output: BuilderOutputLike): Observable { + if (isBuilderOutput(output)) { + return observableOf(output); + } else if (isAsyncIterable(output)) { + return fromAsyncIterable(output); + } else { + return observableFrom(output); + } +} diff --git a/modules/testing/builder/src/builder-harness_spec.ts b/modules/testing/builder/src/builder-harness_spec.ts new file mode 100644 index 000000000000..860764f567f8 --- /dev/null +++ b/modules/testing/builder/src/builder-harness_spec.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +import { TestProjectHost } from '@angular-devkit/architect/testing'; +import { BuilderHarness } from './builder-harness'; + +describe('BuilderHarness', () => { + let mockHost: TestProjectHost; + + beforeEach(() => { + mockHost = jasmine.createSpyObj('TestProjectHost', ['root']); + (mockHost.root as jasmine.Spy).and.returnValue('.'); + }); + + it('uses the provided builder handler', async () => { + const mockHandler = jasmine.createSpy().and.returnValue({ success: true }); + + const harness = new BuilderHarness(mockHandler, mockHost); + + await harness.executeOnce(); + + expect(mockHandler).toHaveBeenCalled(); + }); + + it('provides the builder output result when executing', async () => { + const mockHandler = jasmine.createSpy().and.returnValue({ success: false, property: 'value' }); + + const harness = new BuilderHarness(mockHandler, mockHost); + const { result } = await harness.executeOnce(); + + expect(result).toBeDefined(); + expect(result?.success).toBeFalse(); + expect(result?.property).toBe('value'); + }); + + it('does not show builder logs on console when a builder succeeds', async () => { + const consoleErrorMock = spyOn(console, 'error'); + + const harness = new BuilderHarness(async (_, context) => { + context.logger.warn('TEST WARNING'); + + return { success: true }; + }, mockHost); + + const { result } = await harness.executeOnce(); + + expect(result).toBeDefined(); + expect(result?.success).toBeTrue(); + + expect(consoleErrorMock).not.toHaveBeenCalledWith(jasmine.stringMatching('TEST WARNING')); + }); + + it('shows builder logs on console when a builder fails', async () => { + const consoleErrorMock = spyOn(console, 'error'); + + const harness = new BuilderHarness(async (_, context) => { + context.logger.warn('TEST WARNING'); + + return { success: false }; + }, mockHost); + + const { result } = await harness.executeOnce(); + + expect(result).toBeDefined(); + expect(result?.success).toBeFalse(); + + expect(consoleErrorMock).toHaveBeenCalledWith(jasmine.stringMatching('TEST WARNING')); + }); + + it('does not show builder logs on console when a builder fails and outputLogsOnFailure: false', async () => { + const consoleErrorMock = spyOn(console, 'error'); + + const harness = new BuilderHarness(async (_, context) => { + context.logger.warn('TEST WARNING'); + + return { success: false }; + }, mockHost); + + const { result } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result).toBeDefined(); + expect(result?.success).toBeFalse(); + + expect(consoleErrorMock).not.toHaveBeenCalledWith(jasmine.stringMatching('TEST WARNING')); + }); + + it('provides and logs the builder output exception when builder throws', async () => { + const mockHandler = jasmine.createSpy().and.throwError(new Error('Builder Error')); + const consoleErrorMock = spyOn(console, 'error'); + + const harness = new BuilderHarness(mockHandler, mockHost); + const { result, error } = await harness.executeOnce(); + + expect(result).toBeUndefined(); + expect(error).toEqual(jasmine.objectContaining({ message: 'Builder Error' })); + expect(consoleErrorMock).toHaveBeenCalledWith(jasmine.stringMatching('Builder Error')); + }); + + it('does not log exception with outputLogsOnException false when builder throws', async () => { + const mockHandler = jasmine.createSpy().and.throwError(new Error('Builder Error')); + const consoleErrorMock = spyOn(console, 'error'); + + const harness = new BuilderHarness(mockHandler, mockHost); + const { result, error } = await harness.executeOnce({ outputLogsOnException: false }); + + expect(result).toBeUndefined(); + expect(error).toEqual(jasmine.objectContaining({ message: 'Builder Error' })); + expect(consoleErrorMock).not.toHaveBeenCalledWith(jasmine.stringMatching('Builder Error')); + }); + + it('supports executing a target from within a builder', async () => { + const mockHandler = jasmine.createSpy().and.returnValue({ success: true }); + + const harness = new BuilderHarness(async (_, context) => { + const run = await context.scheduleTarget({ project: 'test', target: 'another' }); + expect(await run.result).toEqual(jasmine.objectContaining({ success: true })); + await run.stop(); + + return { success: true }; + }, mockHost); + harness.withBuilderTarget('another', mockHandler); + + const { result } = await harness.executeOnce(); + + expect(result).toBeDefined(); + expect(result?.success).toBeTrue(); + + expect(mockHandler).toHaveBeenCalled(); + }); +}); diff --git a/modules/testing/builder/src/dev_prod_mode.ts b/modules/testing/builder/src/dev_prod_mode.ts new file mode 100644 index 000000000000..520de82d771b --- /dev/null +++ b/modules/testing/builder/src/dev_prod_mode.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderHarness } from './builder-harness'; + +export const GOOD_TARGET = './src/good.js'; +export const BAD_TARGET = './src/bad.js'; + +/** Setup project for use of conditional imports. */ +export async function setupConditionImport(harness: BuilderHarness) { + // Files that can be used as targets for the conditional import. + await harness.writeFile('src/good.ts', `export const VALUE = 'good-value';`); + await harness.writeFile('src/bad.ts', `export const VALUE = 'bad-value';`); + await harness.writeFile('src/wrong.ts', `export const VALUE = 1;`); + + // Simple application file that accesses conditional code. + await harness.writeFile( + 'src/main.ts', + `import {VALUE} from '#target'; +console.log(VALUE); +console.log(VALUE.length); +export default 42 as any; +`, + ); + + // Ensure that good/bad can be resolved from tsconfig. + const tsconfig = JSON.parse(harness.readFile('src/tsconfig.app.json')) as TypeScriptConfig; + tsconfig.compilerOptions.moduleResolution = 'bundler'; + tsconfig.files.push('good.ts', 'bad.ts', 'wrong.ts'); + await harness.writeFile('src/tsconfig.app.json', JSON.stringify(tsconfig)); +} + +/** Update package.json with the given mapping for #target. */ +export async function setTargetMapping(harness: BuilderHarness, mapping: unknown) { + await harness.writeFile( + 'package.json', + JSON.stringify({ + name: 'ng-test-app', + imports: { + '#target': mapping, + }, + }), + ); +} + +interface TypeScriptConfig { + compilerOptions: { + moduleResolution: string; + }; + files: string[]; +} diff --git a/modules/testing/builder/src/file-watching.ts b/modules/testing/builder/src/file-watching.ts new file mode 100644 index 000000000000..6d10706d6919 --- /dev/null +++ b/modules/testing/builder/src/file-watching.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 + */ + +type BuilderWatcherCallback = ( + events: Array<{ path: string; type: 'created' | 'modified' | 'deleted'; time?: number }>, +) => void; + +interface BuilderWatcherFactory { + watch( + files: Iterable, + directories: Iterable, + callback: BuilderWatcherCallback, + ): { close(): void }; +} + +class WatcherDescriptor { + constructor( + readonly files: ReadonlySet, + readonly directories: ReadonlySet, + readonly callback: BuilderWatcherCallback, + ) {} + + shouldNotify(path: string): boolean { + return true; + } +} + +export class WatcherNotifier implements BuilderWatcherFactory { + private readonly descriptors = new Set(); + + notify(events: Iterable<{ path: string; type: 'modified' | 'deleted' }>): void { + for (const descriptor of this.descriptors) { + for (const { path } of events) { + if (descriptor.shouldNotify(path)) { + descriptor.callback([...events]); + break; + } + } + } + } + + watch( + files: Iterable, + directories: Iterable, + callback: BuilderWatcherCallback, + ): { close(): void } { + const descriptor = new WatcherDescriptor(new Set(files), new Set(directories), callback); + this.descriptors.add(descriptor); + + return { close: () => this.descriptors.delete(descriptor) }; + } +} + +export type { BuilderWatcherFactory }; diff --git a/modules/testing/builder/src/index.ts b/modules/testing/builder/src/index.ts new file mode 100644 index 000000000000..3ee4220781d9 --- /dev/null +++ b/modules/testing/builder/src/index.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export { + BuilderHarness, + type BuilderHarnessExecutionOptions, + type BuilderHarnessExecutionResult, +} from './builder-harness'; +export { + type HarnessFileMatchers, + JasmineBuilderHarness, + describeBuilder, + expectLog, + expectNoLog, +} from './jasmine-helpers'; +export * from './test-utils'; diff --git a/modules/testing/builder/src/jasmine-helpers.ts b/modules/testing/builder/src/jasmine-helpers.ts new file mode 100644 index 000000000000..bd1943ae3ea1 --- /dev/null +++ b/modules/testing/builder/src/jasmine-helpers.ts @@ -0,0 +1,223 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderHandlerFn } from '@angular-devkit/architect'; +import { json, logging } from '@angular-devkit/core'; +import { readFileSync } from 'node:fs'; +import { concatMap, count, debounceTime, firstValueFrom, take, timeout } from 'rxjs'; +import { + BuilderHarness, + BuilderHarnessExecutionOptions, + BuilderHarnessExecutionResult, +} from './builder-harness'; +import { host } from './test-utils'; + +/** + * Maximum time for single build/rebuild + * This accounts for CI variability. + */ +export const BUILD_TIMEOUT = 30_000; + +const optionSchemaCache = new Map(); + +let counter = 0; + +export function describeBuilder( + builderHandler: BuilderHandlerFn, + options: { name?: string; schemaPath: string }, + specDefinitions: (harness: JasmineBuilderHarness) => void, +): void { + let optionSchema = optionSchemaCache.get(options.schemaPath); + if (optionSchema === undefined) { + optionSchema = JSON.parse(readFileSync(options.schemaPath, 'utf8')) as json.schema.JsonSchema; + optionSchemaCache.set(options.schemaPath, optionSchema); + } + const harness = new JasmineBuilderHarness(builderHandler, host, { + builderName: options.name, + optionSchema, + }); + + // The counter is needed to avoid duplicate describe names as they are not allowed. + describe((options.name || builderHandler.name) + ` (Suite: ${counter++})`, () => { + beforeEach(async () => { + harness.resetProjectMetadata(); + + await host.initialize().toPromise(); + }); + + afterEach(() => host.restore().toPromise()); + + specDefinitions(harness); + }); +} + +export class JasmineBuilderHarness extends BuilderHarness { + expectFile(path: string): HarnessFileMatchers { + return expectFile(path, this); + } + expectDirectory(path: string): HarnessDirectoryMatchers { + return expectDirectory(path, this); + } + + async executeWithCases( + cases: (( + executionResult: BuilderHarnessExecutionResult, + index: number, + ) => void | Promise)[], + options?: Partial & { timeout?: number }, + ): Promise { + const executionCount = await firstValueFrom( + this.execute(options).pipe( + timeout(options?.timeout ?? BUILD_TIMEOUT), + debounceTime(100), // This is needed as sometimes 2 events for the same change fire with webpack. + concatMap(async (result, index) => await cases[index](result, index)), + take(cases.length), + count(), + ), + ); + + expect(executionCount).toBe(cases.length); + } +} + +export interface HarnessFileMatchers { + toExist(): boolean; + toNotExist(): boolean; + readonly content: jasmine.ArrayLikeMatchers; + readonly size: jasmine.Matchers; +} + +export interface HarnessDirectoryMatchers { + toExist(): boolean; + toNotExist(): boolean; +} + +/** + * Add a Jasmine expectation filter to an expectation that always fails with a message. + * @param base The base expectation (`expect(...)`) to use. + * @param message The message to provide in the expectation failure. + */ +function createFailureExpectation(base: T, message: string): T { + // Needed typings are not included in the Jasmine types + const expectation = base as T & { + expector: { + addFilter(filter: { + selectComparisonFunc(): () => { pass: boolean; message: string }; + }): typeof expectation.expector; + }; + }; + expectation.expector = expectation.expector.addFilter({ + selectComparisonFunc() { + return () => ({ + pass: false, + message, + }); + }, + }); + + return expectation; +} + +export function expectFile(path: string, harness: BuilderHarness): HarnessFileMatchers { + return { + toExist() { + const exists = harness.hasFile(path); + expect(exists) + .withContext('Expected file to exist: ' + path) + .toBeTrue(); + + return exists; + }, + toNotExist() { + const exists = harness.hasFile(path); + expect(exists) + .withContext('Expected file to exist: ' + path) + .toBeFalse(); + + return !exists; + }, + get content() { + try { + return expect(harness.readFile(path)).withContext(`With file content for '${path}'`); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + throw e; + } + + // File does not exist so always fail the expectation + return createFailureExpectation( + expect(''), + `Expected file content but file does not exist: '${path}'`, + ); + } + }, + get size() { + try { + return expect(Buffer.byteLength(harness.readFile(path))).withContext( + `With file size for '${path}'`, + ); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + throw e; + } + + // File does not exist so always fail the expectation + return createFailureExpectation( + expect(0), + `Expected file size but file does not exist: '${path}'`, + ); + } + }, + }; +} + +export function expectDirectory( + path: string, + harness: BuilderHarness, +): HarnessDirectoryMatchers { + return { + toExist() { + const exists = harness.hasDirectory(path); + expect(exists) + .withContext('Expected directory to exist: ' + path) + .toBeTrue(); + + return exists; + }, + toNotExist() { + const exists = harness.hasDirectory(path); + expect(exists) + .withContext('Expected directory to not exist: ' + path) + .toBeFalse(); + + return !exists; + }, + }; +} + +export function expectLog(logs: readonly logging.LogEntry[], message: string | RegExp) { + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(message), + }), + ); +} + +export function expectNoLog( + logs: readonly logging.LogEntry[], + message: string | RegExp, + failureMessage?: string, +) { + expect(logs) + .withContext(failureMessage ?? '') + .not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(message), + }), + ); +} diff --git a/modules/testing/builder/src/test-utils.ts b/modules/testing/builder/src/test-utils.ts new file mode 100644 index 000000000000..63ded0215c90 --- /dev/null +++ b/modules/testing/builder/src/test-utils.ts @@ -0,0 +1,183 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +import { Architect, BuilderOutput, ScheduleOptions, Target } from '@angular-devkit/architect'; +import { WorkspaceNodeModulesArchitectHost } from '@angular-devkit/architect/node'; +import { TestProjectHost, TestingArchitectHost } from '@angular-devkit/architect/testing'; +import { + Path, + getSystemPath, + join, + json, + normalize, + schema, + virtualFs, + workspaces, +} from '@angular-devkit/core'; +import path from 'node:path'; +import { firstValueFrom } from 'rxjs'; + +// Default timeout for large specs is 60s. +jasmine.DEFAULT_TIMEOUT_INTERVAL = 60_000; + +export const workspaceRoot = join(normalize(__dirname), `../projects/hello-world-app/`); +export const host = new TestProjectHost(workspaceRoot); +export const outputPath: Path = normalize('dist'); + +export const browserTargetSpec = { project: 'app', target: 'build' }; +export const devServerTargetSpec = { project: 'app', target: 'serve' }; +export const extractI18nTargetSpec = { project: 'app', target: 'extract-i18n' }; +export const karmaTargetSpec = { project: 'app', target: 'test' }; +export const tslintTargetSpec = { project: 'app', target: 'lint' }; + +export async function createArchitect(workspaceRoot: Path) { + const registry = new schema.CoreSchemaRegistry(); + registry.addPostTransform(schema.transforms.addUndefinedDefaults); + const workspaceSysPath = getSystemPath(workspaceRoot); + + // The download path is relative (set from Starlark), so before potentially + // changing directories, or executing inside a temporary directory, ensure + // the path is absolute. + if (process.env['PUPPETEER_DOWNLOAD_PATH']) { + process.env.PUPPETEER_DOWNLOAD_PATH = path.resolve(process.env['PUPPETEER_DOWNLOAD_PATH']); + } + + const { workspace } = await workspaces.readWorkspace( + workspaceSysPath, + workspaces.createWorkspaceHost(host), + ); + const architectHost = new TestingArchitectHost( + workspaceSysPath, + workspaceSysPath, + new WorkspaceNodeModulesArchitectHost(workspace, workspaceSysPath), + ); + const architect = new Architect(architectHost, registry); + + return { + workspace, + architectHost, + architect, + }; +} + +export interface BrowserBuildOutput { + output: BuilderOutput; + files: { [file: string]: Promise }; +} + +export async function browserBuild( + architect: Architect, + host: virtualFs.Host, + target: Target, + overrides?: json.JsonObject, + scheduleOptions?: ScheduleOptions, +): Promise { + const run = await architect.scheduleTarget(target, overrides, scheduleOptions); + const output = await run.result; + expect(output.success).toBe(true); + + if (!output.success) { + await run.stop(); + + return { + output, + files: {}, + }; + } + + const [{ path }] = output.outputs; + expect(path).toBeTruthy(); + const outputPath = normalize(path); + + const fileNames = await firstValueFrom(host.list(outputPath)); + const files = fileNames.reduce((acc: { [name: string]: Promise }, path) => { + let cache: Promise | null = null; + Object.defineProperty(acc, path, { + enumerable: true, + get() { + if (cache) { + return cache; + } + if (!fileNames.includes(path)) { + return Promise.reject('No file named ' + path); + } + cache = firstValueFrom(host.read(join(outputPath, path))).then((content) => + virtualFs.fileBufferToString(content), + ); + + return cache; + }, + }); + + return acc; + }, {}); + + await run.stop(); + + return { + output, + files, + }; +} + +export const lazyModuleFiles: { [path: string]: string } = { + 'src/app/lazy/lazy-routing.module.ts': ` + import { NgModule } from '@angular/core'; + import { Routes, RouterModule } from '@angular/router'; + + const routes: Routes = []; + + @NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] + }) + export class LazyRoutingModule { } + `, + 'src/app/lazy/lazy.module.ts': ` + import { NgModule } from '@angular/core'; + import { CommonModule } from '@angular/common'; + + import { LazyRoutingModule } from './lazy-routing.module'; + + @NgModule({ + imports: [ + CommonModule, + LazyRoutingModule + ], + declarations: [] + }) + export class LazyModule { } + `, +}; + +export const lazyModuleFnImport: { [path: string]: string } = { + 'src/app/app.module.ts': ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + + import { AppComponent } from './app.component'; + import { RouterModule } from '@angular/router'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + RouterModule.forRoot([ + { path: 'lazy', loadChildren: () => import('./lazy/lazy.module').then(m => m.LazyModule) } + ]) + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } +`, +}; diff --git a/package.json b/package.json index e2377af595f4..0da54bfa3254 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,8 @@ { "name": "@angular/devkit-repo", - "version": "0.0.0", + "version": "22.3.0-next.4", "private": true, "description": "Software Development Kit for Angular", - "bin": { - "architect": "./bin/architect", - "benchmark": "./bin/benchmark", - "build-optimizer": "./bin/build-optimizer", - "devkit-admin": "./bin/devkit-admin", - "ng": "./bin/ng", - "schematics": "./bin/schematics" - }, "keywords": [ "angular", "Angular CLI", @@ -19,32 +11,30 @@ "Angular DevKit" ], "scripts": { - "admin": "node ./bin/devkit-admin", - "build": "npm run admin -- build", - "build-tsc": "tsc -p tsconfig.json", - "fix": "npm run admin -- lint --fix", - "lint": "npm run admin -- lint", - "prebuildifier": "bazel build --noshow_progress @com_github_bazelbuild_buildtools//buildifier", - "buildifier": "find . -type f \\( -name BUILD -or -name BUILD.bazel \\) ! -path \"*/node_modules/*\" | xargs $(bazel info bazel-bin)/external/com_github_bazelbuild_buildtools/buildifier/buildifier", - "templates": "node ./bin/devkit-admin templates", - "test": "node ./bin/devkit-admin test", - "test-large": "node ./bin/devkit-admin test --large --spec-reporter", - "test-cli-e2e": "node ./tests/legacy-cli/run_e2e", - "test:watch": "nodemon --watch packages -e ts ./bin/devkit-admin test", - "validate": "node ./bin/devkit-admin validate", - "validate-commits": "./bin/devkit-admin validate-commits", - "prepush": "node ./bin/devkit-admin hooks/pre-push", - "preinstall": "node ./tools/yarn/check-yarn.js", - "webdriver-update-appveyor": "webdriver-manager update --standalone false --gecko false --versions.chrome 2.37", - "webdriver-update-circleci": "webdriver-manager update --standalone false --gecko false --versions.chrome $CHROMEDRIVER_VERSION_ARG " + "admin": "node --no-warnings=ExperimentalWarning --experimental-transform-types ./scripts/devkit-admin.mts", + "bazel": "bazelisk", + "test": "bazel test //packages/...", + "build": "pnpm --silent admin build", + "benchmark": "node --no-warnings=ExperimentalWarning --experimental-transform-types --expose-gc ./scripts/devkit-admin.mts benchmark", + "build-schema": "bazel build //... --build_tag_filters schema --symlink_prefix dist-schema/", + "lint": "eslint --cache --max-warnings=0", + "templates": "pnpm --silent admin templates", + "validate": "pnpm --silent admin validate", + "postinstall": "husky", + "ts-circular-deps": "ng-dev ts-circular-deps --config ./scripts/circular-deps-test.conf.mjs", + "check-tooling-setup": "tsc --project .ng-dev/tsconfig.json", + "diff-release-package": "node scripts/diff-release-package.mts" }, "repository": { "type": "git", - "url": "https://github.com/angular/angular-cli.git" + "url": "git+https://github.com/angular/angular-cli.git" }, + "packageManager": "pnpm@12.16.1", "engines": { - "node": ">=10.9.0 <11.0.0", - "yarn": ">=1.9.0 <2.0.0" + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "Please use pnpm instead of NPM to install dependencies", + "yarn": "Please use pnpm instead of Yarn to install dependencies", + "pnpm": "12.16.1" }, "author": "Angular Authors", "license": "MIT", @@ -52,67 +42,102 @@ "url": "https://github.com/angular/angular-cli/issues" }, "homepage": "https://github.com/angular/angular-cli", - "workspaces": { - "packages": [ - "packages/angular/*", - "packages/angular_devkit/*", - "packages/ngtools/*", - "packages/schematics/*" - ], - "nohoist": [ - "@angular/compiler-cli" - ] - }, "dependencies": { - "glob": "^7.0.3", - "node-fetch": "^2.2.0", - "quicktype-core": "^5.0.41", - "temp": "^0.8.3", - "tslint": "^5.11.0", - "typescript": "~3.1.3", - "yarn": "^1.10.1" + "@angular/compiler-cli": "22.3.0-next.3", + "typescript": "6.0.3" }, "devDependencies": { - "@angular/compiler": "^7.0.0-rc.0", - "@angular/compiler-cli": "^7.0.0-rc.0", - "@bazel/typescript": "0.16.1", - "@ngtools/json-schema": "^1.1.0", - "@types/copy-webpack-plugin": "^4.4.1", - "@types/express": "^4.16.0", - "@types/glob": "^5.0.35", - "@types/inquirer": "^0.0.42", - "@types/istanbul": "^0.4.30", - "@types/jasmine": "^2.8.8", - "@types/loader-utils": "^1.1.3", - "@types/minimist": "^1.2.0", - "@types/node": "8.10.10", - "@types/request": "^2.47.1", - "@types/semver": "^5.5.0", - "@types/source-map": "0.5.2", - "@types/webpack": "^4.4.11", - "@types/webpack-dev-server": "^3.1.0", - "@types/webpack-sources": "^0.1.5", - "common-tags": "^1.8.0", - "conventional-changelog": "^1.1.0", - "conventional-commits-parser": "^3.0.0", - "gh-got": "^7.0.0", - "git-raw-commits": "^2.0.0", - "husky": "^0.14.3", - "istanbul": "^0.4.5", - "jasmine": "^2.6.0", - "jasmine-spec-reporter": "^3.2.0", - "license-checker": "^20.1.0", - "minimatch": "^3.0.4", - "minimist": "^1.2.0", - "rxjs": "~6.3.0", - "semver": "^5.3.0", - "source-map": "^0.5.6", - "source-map-support": "^0.5.0", - "spdx-satisfies": "^4.0.0", - "tar": "^4.4.4", - "through2": "^2.0.3", - "ts-node": "^5.0.0", - "tslint-no-circular-imports": "^0.5.0", - "tslint-sonarts": "^1.7.0" + "@angular/animations": "22.3.0-next.3", + "@angular/cdk": "22.3.0-next.4", + "@angular/common": "22.3.0-next.3", + "@angular/compiler": "22.3.0-next.3", + "@angular/core": "22.3.0-next.3", + "@angular/forms": "22.3.0-next.3", + "@angular/localize": "22.3.0-next.3", + "@angular/material": "22.3.0-next.4", + "@angular/ng-dev": "https://github.com/angular/dev-infra-private-ng-dev-builds.git#a53423acd0cec521dcd8edd376b7b6b485ace6cd", + "@angular/platform-browser": "22.3.0-next.3", + "@angular/platform-server": "22.3.0-next.3", + "@angular/router": "22.3.0-next.3", + "@angular/service-worker": "22.3.0-next.3", + "@babel/core": "8.0.9", + "@bazel/bazelisk": "1.28.1", + "@bazel/buildifier": "8.2.1", + "@bazel/ibazel": "^0.28.0", + "@eslint/compat": "2.1.1", + "@eslint/eslintrc": "3.3.7", + "@eslint/js": "10.0.1", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "16.0.3", + "@rollup/wasm-node": "4.65.1", + "@stylistic/eslint-plugin": "^5.0.0", + "@tony.ganchev/eslint-plugin-header": "~3.4.0", + "@types/babel__core": "7.20.5", + "@types/babel__generator": "^7.6.8", + "@types/browser-sync": "^2.27.0", + "@types/express": "~5.0.1", + "@types/http-proxy": "^1.17.4", + "@types/jasmine": "~6.0.0", + "@types/jasmine-reporters": "^2", + "@types/karma": "^6.3.0", + "@types/less": "^3.0.3", + "@types/loader-utils": "^3.0.0", + "@types/lodash": "^4.17.0", + "@types/node": "^22.12.0", + "@types/npm-package-arg": "^6.1.0", + "@types/picomatch": "^4.0.0", + "@types/progress": "^2.0.3", + "@types/semver": "^7.3.12", + "@types/yargs": "^17.0.20", + "@types/yargs-parser": "^21.0.0", + "@typescript-eslint/eslint-plugin": "8.72.1", + "@typescript-eslint/parser": "8.72.1", + "ajv": "8.20.0", + "buffer": "6.0.3", + "esbuild": "0.28.2", + "esbuild-wasm": "0.28.2", + "eslint": "10.13.0", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-import": "2.32.0", + "express": "5.3.0", + "fast-glob": "3.3.3", + "globals": "17.13.0", + "http-proxy": "^1.18.1", + "http-proxy-middleware": "4.2.1", + "husky": "9.1.7", + "jasmine": "~6.3.0", + "jasmine-core": "~6.3.0", + "jasmine-reporters": "^2.5.2", + "jasmine-spec-reporter": "~7.0.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.3.0", + "karma-source-map-support": "1.4.0", + "lodash": "^4.17.21", + "magic-string": "1.4.5", + "prettier": "^3.0.0", + "puppeteer": "25.16.0", + "quicktype-core": "26.0.0", + "rollup": "4.65.1", + "rollup-plugin-dts": "6.5.1", + "rollup-plugin-sourcemaps2": "0.5.10", + "semver": "7.8.5", + "source-map-support": "0.5.21", + "tslib": "2.8.1", + "undici": "8.12.3", + "verdaccio": "6.11.1", + "verdaccio-auth-memory": "^13.0.0", + "zone.js": "^0.16.0" + }, + "dependenciesMeta": { + "esbuild": { + "built": true + }, + "puppeteer": { + "built": true + } } } diff --git a/packages/README.md b/packages/README.md index 82160f26a4f6..aab7eadc1c92 100644 --- a/packages/README.md +++ b/packages/README.md @@ -2,8 +2,7 @@ This folder is the root of all defined packages in this repository. -Packages that are marked as `private: true` will not be published to NPM. These are limited to the -`_` subfolder. +Packages that are marked as `private: true` will not be published to NPM. -This folder includes a directory for every scope in NPM, without the `@` sign. Then one folder +This folder includes a directory for every scope in NPM, without the `@` sign. Then one folder per package, which contains the `package.json`. diff --git a/packages/_/benchmark/package.json b/packages/_/benchmark/package.json deleted file mode 100644 index ba691f860a1a..000000000000 --- a/packages/_/benchmark/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@_/benchmark", - "version": "0.0.0", - "description": "CLI tool for Angular", - "main": "src/index.js", - "typings": "src/index.d.ts", - "scripts": { - "preinstall": "echo DO NOT INSTALL THIS PROJECT, ONLY THE ROOT PROJECT. && exit 1" - }, - "private": true -} diff --git a/packages/_/benchmark/src/benchmark.ts b/packages/_/benchmark/src/benchmark.ts deleted file mode 100644 index 7c22abe0136f..000000000000 --- a/packages/_/benchmark/src/benchmark.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * @license - * 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 - */ -declare const global: { - benchmarkReporter: { - reportBenchmark: Function, - }, -}; - - -const kNanosecondsPerSeconds = 1e9; -const kBenchmarkIterationMaxCount = 10000; -const kBenchmarkTimeoutInMsec = 5000; -const kWarmupIterationCount = 100; -const kTopMetricCount = 5; - - -function _run(fn: (i: number) => void, collector: number[]) { - const timeout = Date.now(); - // Gather the first 5 seconds runs, or kMaxNumberOfIterations runs whichever comes first - // (soft timeout). - for (let i = 0; - i < kBenchmarkIterationMaxCount && (Date.now() - timeout) < kBenchmarkTimeoutInMsec; - i++) { - // Start time. - const start = process.hrtime(); - fn(i); - // Get the stop difference time. - const diff = process.hrtime(start); - - // Add to metrics. - collector.push(diff[0] * kNanosecondsPerSeconds + diff[1]); - } -} - - -function _stats(metrics: number[]) { - metrics.sort((a, b) => a - b); - - const count = metrics.length; - const middle = count / 2; - const mean = Number.isInteger(middle) - ? metrics[middle] : ((metrics[middle - 0.5] + metrics[middle + 0.5]) / 2); - const total = metrics.reduce((acc, curr) => acc + curr, 0); - const average = total / count; - - return { - count: count, - fastest: metrics.slice(0, kTopMetricCount), - slowest: metrics.reverse().slice(0, kTopMetricCount), - mean, - average, - }; -} - - -export function benchmark(name: string, fn: (i: number) => void, base?: (i: number) => void) { - it(name + ' (time in nanoseconds)', (done) => { - process.nextTick(() => { - for (let i = 0; i < kWarmupIterationCount; i++) { - // Warm it up. - fn(i); - } - - const reporter = global.benchmarkReporter; - const metrics: number[] = []; - const baseMetrics: number[] = []; - - _run(fn, metrics); - if (base) { - _run(base, baseMetrics); - } - - reporter.reportBenchmark({ - ..._stats(metrics), - base: base ? _stats(baseMetrics) : null, - }); - - done(); - }); - }); -} diff --git a/packages/_/benchmark/src/index.ts b/packages/_/benchmark/src/index.ts deleted file mode 100644 index df56b2754b0b..000000000000 --- a/packages/_/benchmark/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * @license - * 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 - */ -export * from './benchmark'; diff --git a/packages/_/builders/builders.json b/packages/_/builders/builders.json deleted file mode 100644 index 87914ed792d2..000000000000 --- a/packages/_/builders/builders.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "../architect/src/builders-schema.json", - "builders": { - "true": { - "class": "./src/true", - "schema": "./src/noop-schema.json", - "description": "Always succeed." - } - } -} diff --git a/packages/_/builders/package.json b/packages/_/builders/package.json deleted file mode 100644 index 0f9d3729d481..000000000000 --- a/packages/_/builders/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@_/builders", - "version": "0.0.0", - "description": "CLI tool for Angular", - "main": "src/index.js", - "typings": "src/index.d.ts", - "builders": "builders.json", - "private": true, - "dependencies": { - "rxjs": "6.3.3" - } -} diff --git a/packages/_/builders/src/noop-schema.json b/packages/_/builders/src/noop-schema.json deleted file mode 100644 index afadf0925f37..000000000000 --- a/packages/_/builders/src/noop-schema.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "type": "object" -} \ No newline at end of file diff --git a/packages/_/builders/src/true.ts b/packages/_/builders/src/true.ts deleted file mode 100644 index efe9b7cb0046..000000000000 --- a/packages/_/builders/src/true.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * @license - * 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 - */ -import { Observable, of } from 'rxjs'; - -export class TrueBuilder { - constructor() {} - - run(): Observable<{ success: boolean }> { - return of({ - success: true, - }); - } -} - -export default TrueBuilder; diff --git a/packages/_/devkit/collection.json b/packages/_/devkit/collection.json deleted file mode 100644 index 0353b644307c..000000000000 --- a/packages/_/devkit/collection.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "schematics": { - "package": { - "factory": "./package/factory", - "schema": "./package/schema.json", - "description": "Create an empty schematic project or add a blank schematic to the current project." - } - } -} diff --git a/packages/_/devkit/package.json b/packages/_/devkit/package.json deleted file mode 100644 index 23b4a671ac7e..000000000000 --- a/packages/_/devkit/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "devkit", - "version": "0.0.0", - "description": "Schematics specific to DevKit (used internally, not released)", - "scripts": { - "preinstall": "echo DO NOT INSTALL THIS PROJECT, ONLY THE ROOT PROJECT. && exit 1" - }, - "schematics": "./collection.json", - "private": true, - "dependencies": { - "@angular-devkit/core": "0.0.0", - "@angular-devkit/schematics": "0.0.0" - } -} diff --git a/packages/_/devkit/package/factory.ts b/packages/_/devkit/package/factory.ts deleted file mode 100644 index 9068a3d4ac24..000000000000 --- a/packages/_/devkit/package/factory.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * @license - * 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 - */ -import { JsonAstObject, JsonValue, parseJsonAst } from '@angular-devkit/core'; -import { - Rule, - Tree, - UpdateRecorder, - apply, - chain, - mergeWith, - template, - url, -} from '@angular-devkit/schematics'; -import { Schema } from './schema'; - - -function appendPropertyInAstObject( - recorder: UpdateRecorder, - node: JsonAstObject, - propertyName: string, - value: JsonValue, - indent = 4, -) { - const indentStr = '\n' + new Array(indent + 1).join(' '); - - if (node.properties.length > 0) { - // Insert comma. - const last = node.properties[node.properties.length - 1]; - recorder.insertRight(last.start.offset + last.text.replace(/\s+$/, '').length, ','); - } - - recorder.insertLeft( - node.end.offset - 1, - ' ' - + `"${propertyName}": ${JSON.stringify(value, null, 2).replace(/\n/g, indentStr)}` - + indentStr.slice(0, -2), - ); -} - -function addPackageToMonorepo(options: Schema, path: string): Rule { - return (tree: Tree) => { - const collectionJsonContent = tree.read('/.monorepo.json'); - if (!collectionJsonContent) { - throw new Error('Could not find monorepo.json'); - } - const collectionJsonAst = parseJsonAst(collectionJsonContent.toString('utf-8')); - if (collectionJsonAst.kind !== 'object') { - throw new Error('Invalid monorepo content.'); - } - - const packages = collectionJsonAst.properties.find(x => x.key.value == 'packages'); - if (!packages) { - throw new Error('Cannot find packages key in monorepo.'); - } - if (packages.value.kind != 'object') { - throw new Error('Invalid packages key.'); - } - - const readmeUrl = `https://github.com/angular/angular-cli/blob/master/${path}/README.md`; - - const recorder = tree.beginUpdate('/.monorepo.json'); - appendPropertyInAstObject( - recorder, - packages.value, - options.name, - { - name: options.displayName, - links: [{ label: 'README', url: readmeUrl }], - version: '0.0.1', - hash: '', - }, - ); - tree.commitUpdate(recorder); - }; -} - - -export default function (options: Schema): Rule { - const path = 'packages/' - + options.name - .replace(/^@/, '') - .replace(/-/g, '_'); - - // Verify if we need to create a full project, or just add a new schematic. - const source = apply(url('./project-files'), [ - template({ - ...options as object, - dot: '.', - path, - }), - ]); - - return chain([ - mergeWith(source), - addPackageToMonorepo(options, path), - ]); -} diff --git a/packages/_/devkit/package/project-files/__path__/README.md b/packages/_/devkit/package/project-files/__path__/README.md deleted file mode 100644 index f4b425475360..000000000000 --- a/packages/_/devkit/package/project-files/__path__/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# <%= displayName %> - -Work in progress diff --git a/packages/_/devkit/package/project-files/__path__/package.json b/packages/_/devkit/package/project-files/__path__/package.json deleted file mode 100644 index 7126e5f40667..000000000000 --- a/packages/_/devkit/package/project-files/__path__/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "<%= name %>", - "version": "0.0.0", - "description": "<%= description %>", - "main": "src/index.js", - "typings": "src/index.d.ts", - "scripts": { - "preinstall": "echo DO NOT INSTALL THIS PROJECT, ONLY THE ROOT PROJECT. && exit 1" - }, - "keywords": [ - ], - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "0.0.0" - } -} diff --git a/packages/_/devkit/package/project-files/__path__/src/index.ts b/packages/_/devkit/package/project-files/__path__/src/index.ts deleted file mode 100644 index 258badcf91cd..000000000000 --- a/packages/_/devkit/package/project-files/__path__/src/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @license - * 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 - */ - -// TODO: Make this useful (and awesome). -export default 1; diff --git a/packages/_/devkit/package/schema.d.ts b/packages/_/devkit/package/schema.d.ts deleted file mode 100644 index 0f2b32ac37a9..000000000000 --- a/packages/_/devkit/package/schema.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * @license - * 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 - */ - -export interface Schema { - name: string; - description: string; - displayName: string; -} diff --git a/packages/_/devkit/package/schema.json b/packages/_/devkit/package/schema.json deleted file mode 100644 index 1689719bd985..000000000000 --- a/packages/_/devkit/package/schema.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "id": "SchematicsSchematicSchema", - "title": "DevKit Package Schematic Schema", - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The package name for the new schematic.", - "$default": { - "$source": "argv", - "index": 0 - } - }, - "description": { - "type": "string", - "description": "The description of the new package" - }, - "displayName": { - "type": "string", - "$default": { - "$source": "interpolation", - "value": "${name}" - }, - "description": "The human readable name." - } - } -} diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel new file mode 100644 index 000000000000..d0ab55750e98 --- /dev/null +++ b/packages/angular/build/BUILD.bazel @@ -0,0 +1,403 @@ +load("@devinfra//bazel/api-golden:index.bzl", "api_golden_test_npm_package") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("//tools:defaults.bzl", "copy_to_bin", "jasmine_test", "npm_package", "ts_project") +load("//tools:ts_json_schema.bzl", "ts_json_schema") + +licenses(["notice"]) + +package(default_visibility = ["//visibility:public"]) + +npm_link_all_packages() + +ts_json_schema( + name = "application_schema", + src = "src/builders/application/schema.json", +) + +ts_json_schema( + name = "dev-server_schema", + src = "src/builders/dev-server/schema.json", +) + +ts_json_schema( + name = "extract_i18n_schema", + src = "src/builders/extract-i18n/schema.json", +) + +ts_json_schema( + name = "library_schema", + src = "src/builders/library/schema.json", +) + +ts_json_schema( + name = "ng_karma_schema", + src = "src/builders/karma/schema.json", +) + +ts_json_schema( + name = "ng_packagr_schema", + src = "src/builders/ng-packagr/schema.json", +) + +ts_json_schema( + name = "unit_test_schema", + src = "src/builders/unit-test/schema.json", +) + +copy_to_bin( + name = "schemas", + srcs = glob(["**/schema.json"]), +) + +RUNTIME_ASSETS = glob( + include = [ + "src/**/schema.json", + "src/**/*.js", + ], +) + [ + "builders.json", + "package.json", +] + +ts_project( + name = "build", + srcs = glob( + include = [ + "src/**/*.ts", + ], + exclude = [ + "src/test-utils.ts", + "src/**/*_spec.ts", + "src/**/tests/**/*.ts", + "src/testing/**/*.ts", + ], + ) + [ + "index.ts", + "//packages/angular/build:src/builders/application/schema.ts", + "//packages/angular/build:src/builders/dev-server/schema.ts", + "//packages/angular/build:src/builders/extract-i18n/schema.ts", + "//packages/angular/build:src/builders/karma/schema.ts", + "//packages/angular/build:src/builders/library/schema.ts", + "//packages/angular/build:src/builders/ng-packagr/schema.ts", + "//packages/angular/build:src/builders/unit-test/schema.ts", + ], + data = RUNTIME_ASSETS, + deps = [ + ":node_modules/@ampproject/remapping", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + ":node_modules/@angular/ssr", + ":node_modules/@babel/core", + ":node_modules/@inquirer/confirm", + ":node_modules/@oxc-project/types", + ":node_modules/@parcel/watcher", + ":node_modules/@vitejs/plugin-basic-ssl", + ":node_modules/beasties", + ":node_modules/browserslist", + ":node_modules/chokidar", + ":node_modules/https-proxy-agent", + ":node_modules/istanbul-lib-instrument", + ":node_modules/jsonc-parser", + ":node_modules/less", + ":node_modules/listr2", + ":node_modules/lmdb", + ":node_modules/magic-string", + ":node_modules/mrmime", + ":node_modules/ng-packagr", + ":node_modules/oxc-parser", + ":node_modules/parse5-html-rewriting-stream", + ":node_modules/picomatch", + ":node_modules/piscina", + ":node_modules/postcss", + ":node_modules/rolldown", + ":node_modules/rolldown-plugin-dts", + ":node_modules/rollup", + ":node_modules/sass", + ":node_modules/sass-embedded", + ":node_modules/source-map-support", + ":node_modules/tinyglobby", + ":node_modules/vite", + ":node_modules/vitest", + ":node_modules/xxhash-wasm", + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/localize", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/platform-server", + "//:node_modules/@angular/service-worker", + "//:node_modules/@types/babel__core", + "//:node_modules/@types/karma", + "//:node_modules/@types/less", + "//:node_modules/@types/node", + "//:node_modules/@types/picomatch", + "//:node_modules/@types/semver", + "//:node_modules/esbuild", + "//:node_modules/esbuild-wasm", + "//:node_modules/karma", + "//:node_modules/semver", + "//:node_modules/tslib", + "//:node_modules/typescript", + ], +) + +ts_project( + name = "unit_test_lib", + testonly = True, + srcs = glob( + include = ["src/**/*_spec.ts"], + exclude = ["src/builders/**/tests/**"], + ), + deps = [ + ":build", + ":node_modules/@angular-devkit/core", + ":node_modules/@babel/core", + ":node_modules/@parcel/watcher", + ":node_modules/oxc-parser", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/router", + "//:node_modules/@types/jasmine", + "//:node_modules/esbuild", + "//:node_modules/prettier", + "//:node_modules/typescript", + "//packages/angular/build/private", + ], +) + +jasmine_test( + name = "test", + data = [":unit_test_lib"], + shard_count = 4, +) + +ts_project( + name = "application_integration_test_lib", + testonly = True, + srcs = glob(include = ["src/builders/application/tests/**/*.ts"]), + deps = [ + ":build", + "//packages/angular/build/private", + "//modules/testing/builder", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + + # Base dependencies for the application in hello-world-app. + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/router", + ":node_modules/rxjs", + "//:node_modules/tslib", + "//:node_modules/typescript", + "//:node_modules/zone.js", + "//:node_modules/buffer", + ], +) + +ts_project( + name = "dev-server_integration_test_lib", + testonly = True, + srcs = glob(include = ["src/builders/dev-server/tests/**/*.ts"]), + deps = [ + ":build", + "//packages/angular/build/private", + "//modules/testing/builder", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + + # dev server only test deps + "//:node_modules/@types/http-proxy", + "//:node_modules/@types/node", + "//:node_modules/http-proxy", + "//:node_modules/puppeteer", + + # Base dependencies for the application in hello-world-app. + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/router", + ":node_modules/ng-packagr", + ":node_modules/rxjs", + "//:node_modules/tslib", + "//:node_modules/typescript", + "//:node_modules/zone.js", + "//:node_modules/buffer", + ], +) + +ts_project( + name = "karma_integration_test_lib", + testonly = True, + srcs = glob(include = ["src/builders/karma/tests/**/*.ts"]), + deps = [ + ":build", + "//packages/angular/build/private", + "//modules/testing/builder", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + "//:node_modules/@types/node", + + # karma specific test deps + "//:node_modules/karma-chrome-launcher", + "//:node_modules/karma-coverage", + "//:node_modules/karma-jasmine", + "//:node_modules/karma-jasmine-html-reporter", + "//:node_modules/puppeteer", + + # Base dependencies for the karma in hello-world-app. + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/router", + ":node_modules/rxjs", + "//:node_modules/tslib", + "//:node_modules/typescript", + "//:node_modules/zone.js", + "//:node_modules/buffer", + ], +) + +ts_project( + name = "unit-test_integration_test_lib", + testonly = True, + srcs = glob(include = ["src/builders/unit-test/tests/**/*.ts"]), + deps = [ + ":build", + "//packages/angular/build/private", + "//modules/testing/builder", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + "//:node_modules/@types/node", + + # unit test specific test deps + ":node_modules/@vitest/coverage-v8", + ":node_modules/vitest", + ":node_modules/jsdom", + + # Base dependencies for the hello-world-app. + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/router", + ":node_modules/rxjs", + "//:node_modules/tslib", + "//:node_modules/typescript", + "//:node_modules/zone.js", + ], +) + +ts_project( + name = "library_integration_test_lib", + testonly = True, + srcs = glob(include = ["src/builders/library/tests/**/*.ts"]), + deps = [ + ":build", + "//packages/angular/build/private", + "//modules/testing/builder", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + "//:node_modules/@types/node", + + # Base dependencies for the library in hello-world-lib. + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/router", + ":node_modules/rxjs", + "//:node_modules/tslib", + "//:node_modules/typescript", + "//:node_modules/zone.js", + ], +) + +jasmine_test( + name = "application_integration_tests", + size = "medium", + data = [":application_integration_test_lib"], + shard_count = 25, +) + +jasmine_test( + name = "dev-server_integration_tests", + size = "medium", + data = [":dev-server_integration_test_lib"], + env = { + # Force IPv4 to resolve RBE resolution issues + "NODE_OPTIONS": "--dns-result-order=ipv4first", + }, + shard_count = 10, +) + +jasmine_test( + name = "karma_integration_tests", + size = "medium", + data = [":karma_integration_test_lib"], + env = { + # TODO: Replace Puppeteer downloaded browsers with Bazel-managed browsers, + # or standardize to avoid complex configuration like this! + "PUPPETEER_DOWNLOAD_PATH": "../../../node_modules/puppeteer/downloads", + }, + shard_count = 10, +) + +jasmine_test( + name = "unit-test_integration_tests", + size = "medium", + data = [":unit-test_integration_test_lib"], + shard_count = 10, +) + +jasmine_test( + name = "library_integration_tests", + size = "medium", + data = [":library_integration_test_lib"], + shard_count = 6, +) + +genrule( + name = "license", + srcs = ["//:LICENSE"], + outs = ["LICENSE"], + cmd = "cp $(execpath //:LICENSE) $@", +) + +npm_package( + name = "pkg", + pkg_deps = [ + "//packages/angular_devkit/architect:package.json", + ], + stamp_files = [ + "src/utils/version.js", + "src/tools/esbuild/target.js", + "src/utils/normalize-cache.js", + "src/utils/supported-browsers.js", + ], + tags = ["release-package"], + deps = RUNTIME_ASSETS + [ + ":README.md", + ":build", + ":license", + "//packages/angular/build/private", + ], +) + +api_golden_test_npm_package( + name = "api", + data = [ + ":npm_package", + "//goldens:public-api", + ], + golden_dir = "goldens/public-api/angular/build", + npm_package = "packages/angular/build/npm_package", +) diff --git a/packages/angular/build/README.md b/packages/angular/build/README.md new file mode 100644 index 000000000000..62249f7fe422 --- /dev/null +++ b/packages/angular/build/README.md @@ -0,0 +1,5 @@ +# Angular Build System for Applications and Libraries + +The sources for this package are in the [Angular CLI](https://github.com/angular/angular-cli) repository. Please file issues and pull requests against that repository. + +Usage information and reference details can be found in repository [README](https://github.com/angular/angular-cli/blob/main/README.md) file. diff --git a/packages/angular/build/builders.json b/packages/angular/build/builders.json new file mode 100644 index 000000000000..d73c7a18fe58 --- /dev/null +++ b/packages/angular/build/builders.json @@ -0,0 +1,39 @@ +{ + "builders": { + "application": { + "implementation": "./src/builders/application/index", + "schema": "./src/builders/application/schema.json", + "description": "Build an application." + }, + "dev-server": { + "implementation": "./src/builders/dev-server/index", + "schema": "./src/builders/dev-server/schema.json", + "description": "Execute a development server for an application." + }, + "extract-i18n": { + "implementation": "./src/builders/extract-i18n/index", + "schema": "./src/builders/extract-i18n/schema.json", + "description": "Extract i18n messages from an application." + }, + "karma": { + "implementation": "./src/builders/karma", + "schema": "./src/builders/karma/schema.json", + "description": "Run Karma unit tests." + }, + "library": { + "implementation": "./src/builders/library/index", + "schema": "./src/builders/library/schema.json", + "description": "Build an Angular library package conforming to the Angular Package Format (APF)." + }, + "ng-packagr": { + "implementation": "./src/builders/ng-packagr/index", + "schema": "./src/builders/ng-packagr/schema.json", + "description": "Build a library with ng-packagr." + }, + "unit-test": { + "implementation": "./src/builders/unit-test", + "schema": "./src/builders/unit-test/schema.json", + "description": "Run application unit tests." + } + } +} diff --git a/packages/angular/build/index.ts b/packages/angular/build/index.ts new file mode 100644 index 000000000000..e6da94cc7ded --- /dev/null +++ b/packages/angular/build/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './src/index'; diff --git a/packages/angular/build/package.json b/packages/angular/build/package.json new file mode 100644 index 000000000000..dfe7c94e14a5 --- /dev/null +++ b/packages/angular/build/package.json @@ -0,0 +1,132 @@ +{ + "name": "@angular/build", + "version": "0.0.0-PLACEHOLDER", + "description": "Official build system for Angular", + "keywords": [ + "angular", + "angular-cli" + ], + "exports": { + ".": { + "types": "./src/index.d.ts", + "default": "./src/index.js" + }, + "./private": { + "default": "./src/private.js" + }, + "./package.json": "./package.json" + }, + "builders": "builders.json", + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "workspace:0.0.0-EXPERIMENTAL-PLACEHOLDER", + "@babel/core": "8.0.9", + "@inquirer/confirm": "6.3.5", + "@parcel/watcher": "2.6.0", + "@vitejs/plugin-basic-ssl": "2.3.0", + "beasties": "0.5.5", + "browserslist": "^4.26.0", + "chokidar": "6.0.0", + "esbuild": "0.28.2", + "https-proxy-agent": "9.1.0", + "jsonc-parser": "3.3.1", + "listr2": "11.4.0", + "magic-string": "1.4.5", + "mrmime": "2.0.1", + "oxc-parser": "0.156.0", + "parse5-html-rewriting-stream": "8.0.1", + "picomatch": "4.0.7", + "piscina": "5.3.2", + "rolldown": "1.2.15", + "rolldown-plugin-dts": "0.28.8", + "sass": "1.106.1", + "sass-embedded": "1.106.1", + "semver": "7.8.5", + "source-map-support": "0.5.21", + "tinyglobby": "0.2.17", + "vite": "8.3.5", + "xxhash-wasm": "1.1.0" + }, + "optionalDependencies": { + "@oxc-parser/binding-wasm32-wasi": "0.156.0", + "lmdb": "3.5.9" + }, + "devDependencies": { + "@angular-devkit/core": "workspace:*", + "@angular/ssr": "workspace:*", + "@oxc-project/types": "0.156.0", + "@vitest/coverage-v8": "5.0.5", + "istanbul-lib-instrument": "6.0.3", + "jsdom": "30.1.3", + "less": "4.9.1", + "ng-packagr": "22.3.0-next.2", + "postcss": "8.5.32", + "rollup": "4.65.1", + "rxjs": "7.8.2", + "vitest": "5.0.5" + }, + "peerDependencies": { + "@angular/compiler": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/compiler-cli": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/core": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/localize": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/platform-browser": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/platform-server": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/service-worker": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/ssr": "^0.0.0-PLACEHOLDER", + "istanbul-lib-instrument": "^6.0.0", + "karma": "^6.4.0", + "less": "^4.2.0", + "ng-packagr": "0.0.0-NG-PACKAGR-PEER-DEP", + "postcss": "^8.4.0", + "rollup": "^4.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "tslib": "^2.3.0", + "typescript": ">=6.0 <6.1", + "vitest": "^4.0.8 || ^5.0.0" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "@angular/localize": { + "optional": true + }, + "@angular/platform-browser": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "@angular/ssr": { + "optional": true + }, + "istanbul-lib-instrument": { + "optional": true + }, + "karma": { + "optional": true + }, + "less": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "postcss": { + "optional": true + }, + "rollup": { + "optional": true + }, + "tailwindcss": { + "optional": true + }, + "vitest": { + "optional": true + } + } +} diff --git a/packages/angular/build/private/BUILD.bazel b/packages/angular/build/private/BUILD.bazel new file mode 100644 index 000000000000..c3a100de897f --- /dev/null +++ b/packages/angular/build/private/BUILD.bazel @@ -0,0 +1,11 @@ +load("//tools:defaults.bzl", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "private", + srcs = ["index.ts"], + deps = [ + "//packages/angular/build", + ], +) diff --git a/packages/angular/build/private/index.ts b/packages/angular/build/private/index.ts new file mode 100644 index 000000000000..1c2b76656baf --- /dev/null +++ b/packages/angular/build/private/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from '../src/private'; diff --git a/packages/angular/build/src/builders/application/build-action.ts b/packages/angular/build/src/builders/application/build-action.ts new file mode 100644 index 000000000000..d03d2741b105 --- /dev/null +++ b/packages/angular/build/src/builders/application/build-action.ts @@ -0,0 +1,444 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderContext } from '@angular-devkit/architect'; +import { + BuildOutputAsset, + ExecutionResult, + RebuildState, +} from '../../tools/esbuild/bundler-execution-result'; +import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-files'; +import { + resetSassWorkerPoolCaches, + shutdownSassWorkerPool, +} from '../../tools/esbuild/stylesheets/sass-language'; +import { logMessages, withNoProgress, withSpinner } from '../../tools/esbuild/utils'; +import { initializeHash } from '../../utils/hash'; +import { NormalizedCachedOptions } from '../../utils/normalize-cache'; +import { ChangedFiles } from '../../utils/watcher'; +import { NormalizedApplicationBuildOptions, NormalizedOutputOptions } from './options'; +import { + ComponentUpdateResult, + FullResult, + IncrementalResult, + Result, + ResultKind, + ResultMessage, +} from './results'; + +// eslint-disable-next-line max-lines-per-function +export async function* runEsBuildBuildAction( + action: (rebuildState?: RebuildState) => Promise, + options: { + workspaceRoot: string; + projectRoot: string; + outputOptions: NormalizedOutputOptions; + logger: BuilderContext['logger']; + cacheOptions: NormalizedCachedOptions; + watch?: boolean; + verbose?: boolean; + progress?: boolean; + poll?: number; + signal?: AbortSignal; + preserveSymlinks?: boolean; + clearScreen?: boolean; + colors?: boolean; + jsonLogs?: boolean; + incrementalResults?: boolean; + }, +): AsyncIterable { + const { + watch, + poll, + clearScreen, + logger, + cacheOptions, + outputOptions, + verbose, + projectRoot, + workspaceRoot, + progress, + preserveSymlinks, + colors, + jsonLogs, + incrementalResults, + } = options; + + await initializeHash(); + + const withProgress: typeof withSpinner = progress ? withSpinner : withNoProgress; + + // Initial build + let result: ExecutionResult | undefined; + try { + // Perform the build action + result = await withProgress('Building...', () => action()); + + // Log all diagnostic (error/warning/logs) messages + await logMessages(logger, result, colors, jsonLogs); + } finally { + // Ensure Sass workers are shutdown if not watching or if the initial build failed + if (!watch || !result) { + shutdownSassWorkerPool(); + } + } + + let watcher: import('../../utils/watcher').BuildWatcher | undefined; + let watchLoopStarted = false; + try { + // Setup watcher if watch mode enabled + if (watch) { + if (progress) { + logger.info('Watch mode enabled. Watching for file changes...'); + } + + // Setup a watcher + const { setupWatcher } = await import('../../utils/watcher'); + watcher = await setupWatcher({ + workspaceRoot, + projectRoot, + outputPath: outputOptions.base, + cacheOptions, + poll, + preserveSymlinks, + signal: options.signal, + watchFiles: result.watchFiles, + }); + } + + // Output the first build results after setting up the watcher to ensure that any code executed + // higher in the iterator call stack will trigger the watcher. This is particularly relevant for + // unit tests which execute the builder and modify the file system programmatically. + const outputResults = [...emitOutputResults(result, outputOptions)]; + if (!watch) { + await result.dispose(); + // Set to true to prevent double-disposal of the result context in the finally block on generator exit + watchLoopStarted = true; + } + yield* outputResults; + + // Finish if watch mode is not enabled + if (!watcher) { + return; + } + + watchLoopStarted = true; + } finally { + if (!watchLoopStarted && result) { + await result.dispose(); + } + } + + // Used to force a full result on next rebuild if there were initial errors. + // This ensures at least one full result is emitted. + let hasInitialErrors = result.errors.length > 0; + + // Wait for changes and rebuild as needed + const currentWatchFiles = new Set(result.watchFiles); + try { + for await (const changes of watcher) { + if (options.signal?.aborted) { + break; + } + + if (clearScreen) { + // eslint-disable-next-line no-console + console.clear(); + } + + if (verbose) { + logger.info(changes.toDebugString()); + } + + // Clear removed files from current watch files + changes.removed.forEach((removedPath) => currentWatchFiles.delete(removedPath)); + + const hasSassChanges = changes.all.some((f) => /\.(scss|sass|css)$/i.test(f)); + if (hasSassChanges) { + resetSassWorkerPoolCaches(); + } + + const rebuildState = result.createRebuildState(changes); + result = await withProgress('Changes detected. Rebuilding...', () => action(rebuildState)); + + // Log all diagnostic (error/warning/logs) messages + await logMessages(logger, result, colors, jsonLogs); + + // Update watched locations provided by the new build result. + // Keep watching all previous files if there are any errors; otherwise consider all + // files stale until confirmed present in the new result's watch files. + const staleWatchFiles = result.errors.length > 0 ? undefined : new Set(currentWatchFiles); + for (const watchFile of result.watchFiles) { + if (!currentWatchFiles.has(watchFile)) { + // Add new watch location + watcher.add(watchFile); + currentWatchFiles.add(watchFile); + } + + // Present so remove from stale locations + staleWatchFiles?.delete(watchFile); + } + // Remove any stale locations if the build was successful + if (staleWatchFiles?.size) { + watcher.remove([...staleWatchFiles]); + for (const staleFile of staleWatchFiles) { + currentWatchFiles.delete(staleFile); + } + } + + for (const outputResult of emitOutputResults( + result, + outputOptions, + changes, + incrementalResults && !hasInitialErrors ? rebuildState : undefined, + )) { + yield outputResult; + } + + // Clear initial build errors flag if no errors are now present + hasInitialErrors &&= result.errors.length > 0; + } + } finally { + // Stop the watcher and cleanup incremental rebuild state + await Promise.allSettled([watcher.close(), result.dispose()]); + + shutdownSassWorkerPool(); + } +} + +function* emitOutputResults( + { + outputFiles, + assetFiles, + errors, + warnings, + externalMetadata, + htmlIndexPath, + htmlBaseHref, + templateUpdates, + }: ExecutionResult, + outputOptions: NormalizedApplicationBuildOptions['outputOptions'], + changes?: ChangedFiles, + rebuildState?: RebuildState, +): Iterable { + if (errors.length > 0) { + yield { + kind: ResultKind.Failure, + errors: errors as ResultMessage[], + warnings: warnings as ResultMessage[], + detail: { + outputOptions, + }, + }; + + return; + } + + // Use a full result if there is no rebuild state (no prior build result) + if (!rebuildState || !changes) { + yield createFullResult( + outputFiles, + assetFiles, + warnings, + outputOptions, + externalMetadata, + htmlIndexPath, + htmlBaseHref, + ); + + return; + } + + // Template updates only exist if no other JS changes have occurred. + // A full page reload may be required based on the following incremental output change analysis. + const hasTemplateUpdates = !!templateUpdates?.size; + + // Use an incremental result if previous output information is available + const { previousAssetsInfo, previousOutputInfo } = rebuildState; + + const incrementalResult: IncrementalResult = { + kind: ResultKind.Incremental, + warnings: warnings as ResultMessage[], + // Initially attempt to use a background update of files to support component updates. + background: hasTemplateUpdates, + added: [], + removed: [], + modified: [], + files: [], + detail: { + externalMetadata, + htmlIndexPath, + htmlBaseHref, + outputOptions, + }, + }; + + let hasCssUpdates = false; + + // Initially assume all previous output files have been removed + const removedOutputFiles = new Map(previousOutputInfo); + for (const file of outputFiles) { + const key = `${file.type}:${file.path}`; + removedOutputFiles.delete(key); + + const previousHash = previousOutputInfo.get(key)?.hash; + let needFile = false; + if (previousHash === undefined) { + needFile = true; + incrementalResult.added.push(file.path); + } else if (previousHash !== file.hash) { + needFile = true; + incrementalResult.modified.push(file.path); + } + + if (needFile) { + if (file.path.endsWith('.css')) { + hasCssUpdates = true; + } else if (!canBackgroundUpdate(file)) { + incrementalResult.background = false; + } + + incrementalResult.files.push({ + path: file.path, + type: file.type, + contents: file.contents, + origin: 'memory', + hash: file.hash, + }); + } + } + + // Initially assume all previous assets files have been removed + const removedAssetFiles = new Set(previousAssetsInfo); + for (const { source, destination } of assetFiles) { + removedAssetFiles.delete(destination); + + if (!previousAssetsInfo.has(destination)) { + incrementalResult.added.push(destination); + incrementalResult.background = false; + } else if (changes.modified.has(source) || changes.added.has(source)) { + // A file replaced by a rename (e.g. an atomic save) can be reported as added. + incrementalResult.modified.push(destination); + incrementalResult.background = false; + } else { + continue; + } + + hasCssUpdates ||= destination.endsWith('.css'); + + incrementalResult.files.push({ + path: destination, + type: BuildOutputFileType.Browser, + source, + origin: 'disk', + }); + } + + // Do not remove stale files yet if there are template updates. + // Component chunk files may still be referenced in running browser code. + // Module evaluation time component updates will update any of these files. + // This typically occurs when a lazy component is changed that has not yet + // been accessed at runtime. + if (hasTemplateUpdates && incrementalResult.background) { + removedOutputFiles.clear(); + } + + // Include the removed output and asset files + incrementalResult.removed.push( + ...Array.from(removedOutputFiles.values(), ({ type, path }) => ({ + path, + type, + })), + ...Array.from(removedAssetFiles, (path) => ({ + path, + type: BuildOutputFileType.Browser, + })), + ); + + yield incrementalResult; + + // If there are template updates and the incremental update was background only, a component + // update is possible. + if (hasTemplateUpdates && incrementalResult.background) { + // Template changes may be accompanied by stylesheet changes and these should also be updated hot when possible. + if (hasCssUpdates) { + const styleResult: IncrementalResult = { + kind: ResultKind.Incremental, + added: incrementalResult.added.filter(isCssFilePath), + removed: incrementalResult.removed.filter(({ path }) => isCssFilePath(path)), + modified: incrementalResult.modified.filter(isCssFilePath), + files: incrementalResult.files.filter((file) => isCssFilePath(file.path)), + }; + + yield styleResult; + } + + const updateResult: ComponentUpdateResult = { + kind: ResultKind.ComponentUpdate, + updates: Array.from(templateUpdates, ([id, content]) => ({ + type: 'template', + id, + content, + })), + }; + + yield updateResult; + } +} + +function createFullResult( + outputFiles: readonly BuildOutputFile[], + assetFiles: readonly BuildOutputAsset[], + warnings: readonly unknown[], + outputOptions: NormalizedApplicationBuildOptions['outputOptions'], + externalMetadata: unknown, + htmlIndexPath: unknown, + htmlBaseHref: unknown, +): FullResult { + return { + kind: ResultKind.Full, + warnings: warnings as ResultMessage[], + files: [ + ...assetFiles.map(({ source, destination }) => ({ + path: destination, + type: BuildOutputFileType.Browser, + source, + origin: 'disk' as const, + })), + ...outputFiles.map((file) => ({ + path: file.path, + type: file.type, + contents: file.contents, + origin: 'memory' as const, + hash: file.hash, + })), + ], + detail: { + externalMetadata, + htmlIndexPath, + htmlBaseHref, + outputOptions, + }, + }; +} + +function isCssFilePath(filePath: string): boolean { + return /\.css(?:\.map)?$/i.test(filePath); +} + +function canBackgroundUpdate(file: BuildOutputFile): boolean { + // Files in the output root are not served and do not affect the + // application available with the development server. + if (file.type === BuildOutputFileType.Root) { + return true; + } + + // Updates to non-JS files must signal an update with the dev server + // except the service worker configuration which is special cased. + return /(?:\.m?js|\.map)$/.test(file.path) || file.path === 'ngsw.json'; +} diff --git a/packages/angular/build/src/builders/application/chunk-optimizer.ts b/packages/angular/build/src/builders/application/chunk-optimizer.ts new file mode 100644 index 000000000000..a556ae45e7cd --- /dev/null +++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts @@ -0,0 +1,433 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview This file provides a function to optimize JavaScript chunks using rolldown. + * It is designed to be used after an esbuild build to further optimize the output. + * The main function, `optimizeChunks`, takes the result of an esbuild build, + * identifies the main browser entry point, and then uses rolldown to rebundle + * and optimize the chunks. This process can result in smaller and more efficient + * code by combining and restructuring the original chunks. The file also includes + * helper functions to convert rolldown's output into an esbuild-compatible + * metafile, allowing for consistent analysis and reporting of the build output. + */ + +import type { Message, Metafile } from 'esbuild'; +import assert from 'node:assert'; +import type { Plugin } from 'rollup'; +import { BundleMergedContextResult } from '../../tools/esbuild/bundler-context'; +import { + type BuildOutputFile, + BuildOutputFileType, + createOutputFile, + findTransitiveInitialFiles, +} from '../../tools/esbuild/bundler-files'; +import { useRolldownChunks } from '../../utils/environment-options'; +import { assertIsError } from '../../utils/error'; +import { loadRolldown } from '../../utils/load-rolldown'; +import { toPosixPath } from '../../utils/path'; + +/** + * Represents a minimal subset of a Rollup/Rolldown output asset. + * This is manually defined to avoid hard dependencies on both bundlers' types + * and to ensure compatibility since Rolldown and Rollup types have slight differences + * but share these core properties. + */ +interface OutputAsset { + type: 'asset'; + fileName: string; + source: string | Uint8Array; +} + +/** + * Represents a minimal subset of a Rollup/Rolldown output chunk. + * This is manually defined to avoid hard dependencies on both bundlers' types + * and to ensure compatibility since Rolldown and Rollup types have slight differences + * but share these core properties. + */ +interface OutputChunk { + type: 'chunk'; + fileName: string; + code: string; + modules: Record; + imports: string[]; + dynamicImports?: string[]; + exports: string[]; + isEntry: boolean; + facadeModuleId: string | null | undefined; + map?: { toString(): string } | null; + sourcemapFileName?: string | null; +} + +/** + * Converts the output of a bundle build into an esbuild-compatible metafile. + * @param bundleOutput The output of a bundle build. + * @param originalMetafile The original esbuild metafile from the build. + * @returns An esbuild-compatible metafile. + */ +function bundleOutputToEsbuildMetafile( + bundleOutput: (OutputChunk | OutputAsset)[], + originalMetafile: Metafile, +): Metafile { + const newMetafile: Metafile = { + inputs: originalMetafile.inputs, + outputs: {}, + }; + + const intermediateChunkSizes: Record = {}; + for (const [path, output] of Object.entries(originalMetafile.outputs)) { + intermediateChunkSizes[path] = Object.values(output.inputs).reduce( + (s, i) => s + i.bytesInOutput, + 0, + ); + } + + for (const chunk of bundleOutput) { + if (chunk.type === 'asset') { + newMetafile.outputs[chunk.fileName] = { + bytes: + typeof chunk.source === 'string' + ? Buffer.byteLength(chunk.source, 'utf8') + : chunk.source.length, + inputs: {}, + imports: [], + exports: [], + }; + continue; + } + + const newOutputInputs: Record = {}; + if (chunk.modules) { + for (const [moduleId, renderedModule] of Object.entries(chunk.modules)) { + const originalOutputEntry = originalMetafile.outputs[moduleId]; + if (!originalOutputEntry?.inputs) { + continue; + } + + const totalOriginalBytesInModule = intermediateChunkSizes[moduleId]; + if (totalOriginalBytesInModule === 0) { + continue; + } + + // Read once per module: in Rolldown, `renderedLength` is an uncached getter that + // copies the entire module code across the NAPI bridge on each access. + const { renderedLength } = renderedModule; + + for (const [originalInputPath, originalInputInfo] of Object.entries( + originalOutputEntry.inputs, + )) { + const proportion = originalInputInfo.bytesInOutput / totalOriginalBytesInModule; + const newBytesInOutput = Math.floor(renderedLength * proportion); + + const existing = newOutputInputs[originalInputPath]; + if (existing) { + existing.bytesInOutput += newBytesInOutput; + } else { + newOutputInputs[originalInputPath] = { bytesInOutput: newBytesInOutput }; + } + + if (!newMetafile.inputs[originalInputPath]) { + newMetafile.inputs[originalInputPath] = originalMetafile.inputs[originalInputPath]; + } + } + } + } + + const imports = [ + ...chunk.imports.map((path) => ({ path, kind: 'import-statement' as const })), + ...(chunk.dynamicImports?.map((path) => ({ path, kind: 'dynamic-import' as const })) ?? []), + ]; + + let entryPoint: string | undefined; + if (chunk.facadeModuleId) { + const posixFacadeModuleId = toPosixPath(chunk.facadeModuleId); + for (const [outputPath, output] of Object.entries(originalMetafile.outputs)) { + if (posixFacadeModuleId.endsWith(outputPath)) { + entryPoint = output.entryPoint; + break; + } + } + } + + newMetafile.outputs[chunk.fileName] = { + bytes: Buffer.byteLength(chunk.code, 'utf8'), + inputs: newOutputInputs, + imports, + exports: chunk.exports ?? [], + entryPoint, + }; + } + + return newMetafile; +} + +/** + * Creates an esbuild message object for a chunk optimization failure. + * @param message The error message detailing the cause of the failure. + * @returns A partial esbuild message object. + */ +function createChunkOptimizationFailureMessage(message: string): Message { + // Most of these fields are not actually needed for printing the error + return { + id: '', + text: 'Chunk optimization failed', + detail: undefined, + pluginName: '', + location: null, + notes: [ + { + text: message, + location: null, + }, + ], + }; +} + +/** + * Optimizes the chunks of a build result using rolldown. + * + * This function takes the output of an esbuild build, identifies the main browser entry point, + * and uses rolldown to bundle and optimize the JavaScript chunks. The optimized chunks + * replace the original ones in the build result, and the metafile is updated to reflect + * the changes. + * + * @param original The original build result from esbuild. + * @param sourcemap A boolean or 'hidden' to control sourcemap generation. + * @returns A promise that resolves to the updated build result with optimized chunks. + */ +// eslint-disable-next-line max-lines-per-function +export async function optimizeChunks( + original: BundleMergedContextResult, + sourcemap: boolean | 'hidden', +): Promise { + // Failed builds cannot be optimized + if (original.errors) { + return original; + } + + // Find the main browser entrypoint + let mainFile; + for (const [file, record] of original.initialFiles) { + if ( + record.name === 'main' && + record.entrypoint && + !record.serverFile && + record.type === 'script' + ) { + mainFile = file; + break; + } + } + + // No action required if no browser main entrypoint or metafile for stats + if (!mainFile || !original.metafiles.browser) { + return original; + } + + const chunks: Record = {}; + const maps: Record = {}; + for (const originalFile of original.outputFiles) { + if (originalFile.type !== BuildOutputFileType.Browser) { + continue; + } + + if (originalFile.path.endsWith('.js')) { + chunks[originalFile.path] = originalFile; + } else if (originalFile.path.endsWith('.js.map')) { + // Create mapping of JS file to sourcemap content + maps[originalFile.path.slice(0, -4)] = originalFile; + } + } + + const usedChunks = new Set(); + + let bundle; + let optimizedOutput: (OutputChunk | OutputAsset)[]; + try { + const plugins = [ + { + name: 'angular-bundle', + resolveId(source: string) { + // Remove leading `./` if present + const file = source[0] === '.' && source[1] === '/' ? source.slice(2) : source; + + if (chunks[file]) { + return file; + } + + // All other identifiers are considered external to maintain behavior + return { id: source, external: true }; + }, + load(id: string) { + assert( + chunks[id], + `Angular chunk content should always be present in chunk optimizer [${id}].`, + ); + + usedChunks.add(id); + + const result = { + code: chunks[id].text, + map: maps[id]?.text, + }; + + return result; + }, + ...(sourcemap + ? { + augmentChunkHash(chunk: { moduleIds: string[] }) { + let hash = ''; + for (const id of chunk.moduleIds) { + const mapHash = maps[id]?.hash; + if (mapHash) { + hash += mapHash; + } + } + + return hash || undefined; + }, + } + : {}), + }, + ]; + + const chunkFileNames = (chunkInfo: { name: string }) => + `${chunkInfo.name.replace(/-[a-zA-Z0-9_-]{8}$/, '')}-[hash].js`; + + if (useRolldownChunks) { + const { rolldown } = await loadRolldown(); + bundle = await rolldown({ + input: mainFile, + // Allow Rolldown to extend the main entry chunk's exports when merging shared initial + // modules into `main` so that lazy chunks can import those symbols directly from `main`. + preserveEntrySignatures: 'allow-extension', + plugins, + }); + + let result = await bundle.generate({ + minify: { mangle: false, compress: false }, + sourcemap, + codeSplitting: { + // Consolidate all modules statically reachable from the initial entry point (`$initial`) + // back into the `main` chunk. This prevents multi-level initial chunk waterfalls caused + // by esbuild splitting out modules that are shared across `main` and subsets of lazy + // routes, while keeping shared chunks used exclusively by lazy routes separate. + groups: [{ name: 'main', tags: ['$initial'] }], + }, + chunkFileNames, + }); + + // If any module in `$initial` uses top-level `await`, Rolldown cannot fold the manual + // `main` group into the entry chunk and emits an empty entry facade instead. Fall back to + // Rolldown's default TLA-aware chunking in that case. + if ( + result.output.some( + (chunk) => + chunk.type === 'chunk' && chunk.isEntry && Object.keys(chunk.modules).length === 0, + ) + ) { + result = await bundle.generate({ + minify: { mangle: false, compress: false }, + sourcemap, + chunkFileNames, + }); + } + + optimizedOutput = result.output; + } else { + let rollup; + try { + rollup = (await import('rollup')).rollup; + } catch { + throw new Error( + `Rollup is required when 'NG_BUILD_CHUNKS_ROLLDOWN' is set to false. ` + + `Please install 'rollup' manually (e.g. 'npm install rollup --save-dev') to use this fallback.`, + ); + } + bundle = await rollup({ + input: mainFile, + plugins: plugins as Plugin[], + }); + + const result = await bundle.generate({ + compact: true, + sourcemap, + chunkFileNames, + }); + optimizedOutput = result.output; + } + } catch (e) { + assertIsError(e); + + return { + errors: [createChunkOptimizationFailureMessage(e.message)], + warnings: original.warnings, + }; + } finally { + await bundle?.close(); + } + + // Update metafile + const newMetafile = bundleOutputToEsbuildMetafile(optimizedOutput, original.metafiles.browser); + // Add back the outputs that were not part of the optimization + for (const [path, output] of Object.entries(original.metafiles.browser.outputs)) { + if (usedChunks.has(path)) { + continue; + } + + newMetafile.outputs[path] = output; + for (const inputPath of Object.keys(output.inputs)) { + if (!newMetafile.inputs[inputPath]) { + newMetafile.inputs[inputPath] = original.metafiles.browser.inputs[inputPath]; + } + } + } + original.metafiles.browser = newMetafile; + + // Remove used chunks and associated sourcemaps from the original result + original.outputFiles = original.outputFiles.filter( + (file) => + !usedChunks.has(file.path) && + !(file.path.endsWith('.map') && usedChunks.has(file.path.slice(0, -4))), + ); + + // Add new optimized chunks + for (const optimizedFile of optimizedOutput) { + if (optimizedFile.type !== 'chunk') { + continue; + } + + original.outputFiles.push( + createOutputFile(optimizedFile.fileName, optimizedFile.code, BuildOutputFileType.Browser), + ); + if (optimizedFile.map && optimizedFile.sourcemapFileName) { + original.outputFiles.push( + createOutputFile( + optimizedFile.sourcemapFileName, + optimizedFile.map.toString(), + BuildOutputFileType.Browser, + ), + ); + } + } + + // Update initial files to reflect optimized chunks + for (const usedFile of usedChunks) { + // Leave the main file since its information did not change + if (usedFile !== mainFile) { + original.initialFiles.delete(usedFile); + } + } + + // Analyze for transitive initial files + const mainRecord = original.initialFiles.get(mainFile); + assert(mainRecord, `Main file record should always be present in initialFiles [${mainFile}].`); + findTransitiveInitialFiles(newMetafile, original.initialFiles, [[mainFile, mainRecord]]); + + return original; +} diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts new file mode 100644 index 000000000000..7fc43de92b00 --- /dev/null +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -0,0 +1,471 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderContext } from '@angular-devkit/architect'; +import type { Metafile } from 'esbuild'; +import { createAngularCompilation } from '../../tools/angular/compilation'; +import { + AngularCompilationContext, + PrimaryCompilationContext, +} from '../../tools/esbuild/angular/compilation-state'; +import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache'; +import { generateBudgetStats } from '../../tools/esbuild/budget-stats'; +import { BundleContextResult, BundlerContext } from '../../tools/esbuild/bundler-context'; +import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result'; +import { BuildOutputFileType } from '../../tools/esbuild/bundler-files'; +import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker'; +import { LOCALE_DATA_BASE_MODULE } from '../../tools/esbuild/i18n-locale-plugin'; +import { extractLicenses } from '../../tools/esbuild/license-extractor'; +import { transformSupportedBrowsersToTargets } from '../../tools/esbuild/target'; +import { + calculateEstimatedTransferSizes, + isZonelessApp, + logBuildStats, +} from '../../tools/esbuild/utils'; +import { BudgetCalculatorResult, checkBudgets } from '../../utils/bundle-calculator'; +import { optimizeChunksThreshold } from '../../utils/environment-options'; +import { profileAsync } from '../../utils/profiling'; +import { resolveAssets } from '../../utils/resolve-assets'; +import { + SERVER_APP_ENGINE_MANIFEST_FILENAME, + generateAngularServerAppEngineManifest, +} from '../../utils/server-rendering/manifest'; +import { getSupportedBrowsers } from '../../utils/supported-browsers'; +import { executePostBundleSteps } from './execute-post-bundle'; +import { inlineI18n, loadActiveTranslations } from './i18n'; +import { NormalizedApplicationBuildOptions } from './options'; +import { createComponentStyleBundler, setupBundlerContexts } from './setup-bundling'; + +/** The esbuild error text prefix used to detect top-level await errors. */ +const TOP_LEVEL_AWAIT_ERROR_TEXT = + 'Top-level await is not available in the configured target environment'; + +/** Returns a shallow copy of a record with its keys sorted. */ +function sortObjectKeys>(value: T): T { + const sorted: Record = {}; + for (const key of Object.keys(value).sort()) { + sorted[key] = value[key]; + } + + return sorted as T; +} + +/** + * Serializes a metafile with its dynamic path keys sorted so the stats file is identical across + * builds. Only `inputs`, `outputs`, and each output's `inputs` have dynamic keys; the remaining + * fixed-schema objects are left untouched so V8 keeps its native `JSON.stringify` fast path. + */ +function stableStringify(metafile: Metafile): string { + const outputs: Metafile['outputs'] = {}; + for (const key of Object.keys(metafile.outputs).sort()) { + const output = metafile.outputs[key]; + outputs[key] = { + ...output, + inputs: sortObjectKeys(output.inputs), + }; + } + + return JSON.stringify( + { + inputs: sortObjectKeys(metafile.inputs), + outputs, + }, + null, + 2, + ); +} + +// eslint-disable-next-line max-lines-per-function +export async function executeBuild( + options: NormalizedApplicationBuildOptions, + context: BuilderContext, + rebuildState?: RebuildState, +): Promise { + const { + projectRoot, + workspaceRoot, + i18nOptions, + optimizationOptions, + assets, + cacheOptions, + serverEntryPoint, + baseHref, + ssrOptions, + verbose, + colors, + jsonLogs, + security, + } = options; + + // TODO: Consider integrating into watch mode. Would require full rebuild on target changes. + const browsers = getSupportedBrowsers(projectRoot, context.logger); + + // Load active translations if inlining + // TODO: Integrate into watch mode and only load changed translations + if (i18nOptions.shouldInline) { + await loadActiveTranslations(context, i18nOptions); + } + + // Reuse rebuild state or create new bundle contexts for code and global stylesheets + let bundlerContexts; + let componentStyleBundler; + let codeBundleCache; + let bundlingIndividualResults: BundleContextResult[]; + let templateUpdates: Map | undefined; + let angularCompilationContext: AngularCompilationContext | undefined; + let executionResult: ExecutionResult | undefined; + try { + if (rebuildState) { + bundlerContexts = rebuildState.rebuildContexts; + componentStyleBundler = rebuildState.componentStyleBundler; + codeBundleCache = rebuildState.codeBundleCache; + templateUpdates = rebuildState.templateUpdates; + executionResult = new ExecutionResult( + bundlerContexts, + componentStyleBundler, + codeBundleCache, + templateUpdates, + ); + // Reset template updates for new rebuild + templateUpdates?.clear(); + + const allFileChanges = rebuildState.fileChanges.all; + + // Bundle all contexts that do not require TypeScript changed file checks. + // These will automatically use cached results based on the changed files. + bundlingIndividualResults = await BundlerContext.bundleAll( + bundlerContexts.otherContexts, + allFileChanges, + ); + + // Check the TypeScript code bundling cache for changes. If invalid, force a rebundle of + // all TypeScript related contexts. + const forceTypeScriptRebuild = codeBundleCache?.invalidate(allFileChanges); + const typescriptResults: BundleContextResult[] = []; + for (const typescriptContext of bundlerContexts.typescriptContexts) { + typescriptContext.invalidate(allFileChanges); + const result = await typescriptContext.bundle(forceTypeScriptRebuild); + typescriptResults.push(result); + } + + bundlingIndividualResults.push(...typescriptResults); + } else { + const target = transformSupportedBrowsersToTargets(browsers); + codeBundleCache = new SourceFileCache(cacheOptions.enabled ? cacheOptions.path : undefined); + componentStyleBundler = createComponentStyleBundler(options, target); + if (options.templateUpdates) { + templateUpdates = new Map(); + } + const angularCompilation = await createAngularCompilation( + !!options.jit, + !options.serverEntryPoint, + ); + angularCompilationContext = new PrimaryCompilationContext(angularCompilation); + bundlerContexts = setupBundlerContexts( + options, + target, + codeBundleCache, + componentStyleBundler, + angularCompilationContext, + templateUpdates, + ); + executionResult = new ExecutionResult( + bundlerContexts, + componentStyleBundler, + codeBundleCache, + templateUpdates, + ); + + // Bundle everything on initial build + bundlingIndividualResults = await BundlerContext.bundleAll([ + ...bundlerContexts.typescriptContexts, + ...bundlerContexts.otherContexts, + ]); + } + + // Update any external component styles if enabled and rebuilding. + // TODO: Only attempt rebundling of invalidated styles once incremental build results are supported. + if (rebuildState && options.externalRuntimeStyles) { + componentStyleBundler.invalidate(rebuildState.fileChanges.all); + + const componentResults = await componentStyleBundler.bundleAllFiles(true, true); + bundlingIndividualResults.push(...componentResults); + } + + let bundlingResult = BundlerContext.mergeResults(bundlingIndividualResults); + + executionResult.addWarnings(bundlingResult.warnings); + + // Add used external component style referenced files to be watched + if (options.externalRuntimeStyles) { + executionResult.extraWatchFiles.push(...componentStyleBundler.collectReferencedFiles()); + } + + // In non-watch mode, the bundler rebuild contexts and Angular compilation + // are no longer needed once bundling completes. Dispose them early to free + // worker processes, TypeScript ASTs, and caches before post-bundling steps + // execute or before returning bundling errors. + // This is fire-and-forget so worker teardown runs concurrently in the background + // without blocking the critical path of post-bundle optimization steps. + // Any in-flight disposal is awaited in the builder action's finally block. + if (!options.watch) { + void angularCompilationContext?.dispose(); + void executionResult.dispose(); + } + + // Return if the bundling has errors + if (bundlingResult.errors) { + // If Zone.js is used, augment top-level await errors with a more helpful message. + // esbuild's default error mentions "target environment" with browser versions, but + // the actual reason is that async/await is downleveled for Zone.js compatibility. + if (!isZonelessApp(options.polyfills)) { + for (const error of bundlingResult.errors) { + if (error.text?.startsWith(TOP_LEVEL_AWAIT_ERROR_TEXT)) { + error.notes ??= []; + error.notes.push({ + text: + 'Top-level await is not supported in applications that use Zone.js. ' + + 'Consider removing Zone.js or moving this code into an async function. \n' + + 'For more information about zoneless Angular applications, visit: https://angular.dev/guide/zoneless', + location: null, + }); + } + } + } + + executionResult.addErrors(bundlingResult.errors); + + return executionResult; + } + + // Optimize chunks if enabled and threshold is met. + // This pass uses Rollup/Rolldown to further optimize chunks generated by esbuild. + if (options.optimizationOptions.scripts) { + // Count lazy chunks (files not needed for initial load). + // Advanced chunk optimization is most beneficial when there are multiple lazy chunks. + const { metafiles, initialFiles } = bundlingResult; + const lazyChunksCount = Object.keys(metafiles.browser.outputs || {}).filter( + (path) => path.endsWith('.js') && !initialFiles.has(path), + ).length; + + // Only run if the number of lazy chunks meets the configured threshold. + // This avoids overhead for small projects with few chunks. + if (lazyChunksCount >= optimizeChunksThreshold) { + const { optimizeChunks } = await import('./chunk-optimizer'); + const optimizationResult = await profileAsync('OPTIMIZE_CHUNKS', () => + optimizeChunks( + bundlingResult, + options.sourcemapOptions.scripts ? !options.sourcemapOptions.hidden || 'hidden' : false, + ), + ); + + if (optimizationResult.errors) { + executionResult.addErrors(optimizationResult.errors); + + return executionResult; + } + + bundlingResult = optimizationResult; + } + } + + // Analyze external imports if external options are enabled + if (options.externalPackages || bundlingResult.externalConfiguration) { + const { + externalConfiguration = [], + externalImports: { browser = [], server = [] }, + } = bundlingResult; + // Similar to esbuild, --external:@foo/bar automatically implies --external:@foo/bar/*, + // which matches import paths like @foo/bar/baz. + // This means all paths within the @foo/bar package are also marked as external. + const exclusionsPrefixes = externalConfiguration.map((exclusion) => exclusion + '/'); + const exclusions = new Set(externalConfiguration); + const explicitExternal = new Set(); + + const isExplicitExternal = (dep: string): boolean => { + // Locale-data imports are injected by the builder itself (see i18n-locale-plugin) and must stay resolvable + // even when the containing package is externalized by the user. + if (dep.startsWith(`${LOCALE_DATA_BASE_MODULE}/`)) { + return false; + } + + if (exclusions.has(dep)) { + return true; + } + + for (const prefix of exclusionsPrefixes) { + if (dep.startsWith(prefix)) { + return true; + } + } + + return false; + }; + + const implicitBrowser: string[] = []; + for (const dep of browser) { + if (isExplicitExternal(dep)) { + explicitExternal.add(dep); + } else { + implicitBrowser.push(dep); + } + } + + const implicitServer: string[] = []; + for (const dep of server) { + if (isExplicitExternal(dep)) { + explicitExternal.add(dep); + } else { + implicitServer.push(dep); + } + } + + executionResult.setExternalMetadata(implicitBrowser, implicitServer, [...explicitExternal]); + } + + const { + metafiles: { browser: browserMetafile, server: serverMetafile }, + initialFiles, + outputFiles, + } = bundlingResult; + const metafiles = [browserMetafile, serverMetafile]; + + executionResult.outputFiles.push(...outputFiles); + + // Analyze files for bundle budget failures if present + let budgetFailures: BudgetCalculatorResult[] | undefined; + if (options.budgets) { + const compatStats = generateBudgetStats(browserMetafile, outputFiles, initialFiles); + budgetFailures = [...checkBudgets(options.budgets, compatStats, true)]; + for (const { message, severity } of budgetFailures) { + if (severity === 'error') { + executionResult.addError(message); + } else { + executionResult.addWarning(message); + } + } + } + + // Calculate estimated transfer size if scripts are optimized + let estimatedTransferSizes; + if (optimizationOptions.scripts || optimizationOptions.styles.minify) { + estimatedTransferSizes = await calculateEstimatedTransferSizes(executionResult.outputFiles); + } + + // Check metafile for CommonJS module usage if optimizing scripts + if (optimizationOptions.scripts) { + const messages = checkCommonJSModules(browserMetafile, options.allowedCommonJsDependencies); + executionResult.addWarnings(messages); + } + + // Copy assets + if (assets) { + executionResult.addAssets(await resolveAssets(assets, workspaceRoot)); + } + + // Extract and write licenses for used packages + if (options.extractLicenses) { + executionResult.addOutputFile( + '3rdpartylicenses.txt', + await extractLicenses(metafiles, workspaceRoot), + BuildOutputFileType.Root, + ); + } + + // Watch input index HTML file if configured + if (options.indexHtmlOptions) { + executionResult.extraWatchFiles.push(options.indexHtmlOptions.input); + executionResult.htmlIndexPath = options.indexHtmlOptions.output; + executionResult.htmlBaseHref = options.baseHref; + } + + // Create server app engine manifest + if (serverEntryPoint) { + executionResult.addOutputFile( + SERVER_APP_ENGINE_MANIFEST_FILENAME, + generateAngularServerAppEngineManifest(i18nOptions, security.allowedHosts, baseHref), + BuildOutputFileType.ServerRoot, + ); + } + + // Perform i18n translation inlining if enabled + if (i18nOptions.shouldInline) { + const result = await inlineI18n(browserMetafile, options, executionResult, initialFiles); + executionResult.addErrors(result.errors); + executionResult.addWarnings(result.warnings); + executionResult.addPrerenderedRoutes(result.prerenderedRoutes); + } else { + const result = await executePostBundleSteps( + browserMetafile, + options, + executionResult.outputFiles, + executionResult.assetFiles, + initialFiles, + // Set lang attribute to the defined source locale if present + i18nOptions.hasDefinedSourceLocale ? i18nOptions.sourceLocale : undefined, + ); + + // Deduplicate and add errors and warnings + executionResult.addErrors([...new Set(result.errors)]); + executionResult.addWarnings([...new Set(result.warnings)]); + + executionResult.addPrerenderedRoutes(result.prerenderedRoutes); + executionResult.outputFiles.push(...result.additionalOutputFiles); + executionResult.assetFiles.push(...result.additionalAssets); + } + + if (serverEntryPoint) { + executionResult.addOutputFile( + 'prerendered-routes.json', + JSON.stringify({ routes: executionResult.prerenderedRoutes }, null, 2), + BuildOutputFileType.Root, + ); + } + + // Write metafiles if stats option is enabled + if (options.stats) { + executionResult.addOutputFile( + 'browser-stats.json', + stableStringify(browserMetafile), + BuildOutputFileType.Root, + ); + + if (serverEntryPoint) { + executionResult.addOutputFile( + 'server-stats.json', + stableStringify(serverMetafile), + BuildOutputFileType.Root, + ); + } + } + + if (!jsonLogs && !options.quiet) { + const changedFiles = + rebuildState && executionResult.findChangedFiles(rebuildState.previousOutputInfo); + executionResult.addLog( + logBuildStats( + metafiles, + outputFiles, + initialFiles, + budgetFailures, + colors, + changedFiles, + estimatedTransferSizes, + !!ssrOptions, + verbose, + ), + ); + } + + return executionResult; + } catch (error) { + await Promise.allSettled([angularCompilationContext?.dispose(), executionResult?.dispose()]); + + throw error; + } +} diff --git a/packages/angular/build/src/builders/application/execute-post-bundle.ts b/packages/angular/build/src/builders/application/execute-post-bundle.ts new file mode 100644 index 000000000000..99e23bcb30c9 --- /dev/null +++ b/packages/angular/build/src/builders/application/execute-post-bundle.ts @@ -0,0 +1,266 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Metafile } from 'esbuild'; +import assert from 'node:assert'; +import { + type BuildOutputAsset, + type PrerenderedRoutesRecord, +} from '../../tools/esbuild/bundler-execution-result'; +import { + type BuildOutputFile, + BuildOutputFileType, + type InitialFileRecord, + createOutputFile, +} from '../../tools/esbuild/bundler-files'; +import { maxWorkers } from '../../utils/environment-options'; +import { + SERVER_APP_MANIFEST_FILENAME, + generateAngularServerAppManifest, +} from '../../utils/server-rendering/manifest'; +import { WritableSerializableRouteTreeNode } from '../../utils/server-rendering/models'; +import { prerenderPages } from '../../utils/server-rendering/prerender'; +import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker'; +import { generateIndexHtml } from './index-html-generator'; +import { injectDebugIds } from './inject-debug-ids'; +import { INDEX_HTML_CSR, INDEX_HTML_SERVER, NormalizedApplicationBuildOptions } from './options'; +import { OutputMode } from './schema'; + +/** + * Run additional builds steps including SSG, AppShell, Index HTML file and Service worker generation. + * @param browserMetafile An esbuild metafile object. + * @param options The normalized application builder options used to create the build. + * @param outputFiles The output files of an executed build. + * @param assetFiles The assets of an executed build. + * @param initialFiles A map containing initial file information for the executed build. + * @param locale A language locale to insert in the index.html. + */ +// eslint-disable-next-line max-lines-per-function +export async function executePostBundleSteps( + browserMetafile: Metafile, + options: NormalizedApplicationBuildOptions, + outputFiles: BuildOutputFile[], + assetFiles: BuildOutputAsset[], + initialFiles: Map, + locale: string | undefined, +): Promise<{ + errors: string[]; + warnings: string[]; + additionalOutputFiles: BuildOutputFile[]; + additionalAssets: BuildOutputAsset[]; + prerenderedRoutes: PrerenderedRoutesRecord; +}> { + const additionalAssets: BuildOutputAsset[] = []; + const additionalOutputFiles: BuildOutputFile[] = []; + const allErrors: string[] = []; + const allWarnings: string[] = []; + const prerenderedRoutes: PrerenderedRoutesRecord = {}; + + const { + baseHref = '/', + serviceWorker, + ssrOptions, + indexHtmlOptions, + optimizationOptions, + sourcemapOptions, + outputMode, + serverEntryPoint, + prerenderOptions, + appShellOptions, + publicPath, + workspaceRoot, + partialSSRBuild, + } = options; + + // Embed ECMA-426 Debug IDs into JS/source-map pairs before any consumer reads the bytes (in + // particular `generateIndexHtml` below, which computes SRI hashes from the on-disk content). + // Doing this here also covers the i18n path, where this function is invoked once per locale + // with locale-specific output files. Files without a source map sibling are skipped. + if (sourcemapOptions.scripts) { + injectDebugIds(outputFiles); + } + + // Index HTML content without CSS inlining to be used for server rendering (AppShell, SSG and SSR). + // NOTE: Critical CSS inlining is deliberately omitted here, as it will be handled during server rendering. + // Additionally, when using prerendering or AppShell, the index HTML file may be regenerated. + // To prevent generating duplicate files with the same filename, a `Map` is used to store and manage the files. + const additionalHtmlOutputFiles = new Map(); + + // Generate index HTML file + // If localization is enabled, index generation is handled in the inlining process. + if (indexHtmlOptions) { + const { csrContent, ssrContent, errors, warnings } = await generateIndexHtml( + initialFiles, + outputFiles, + options, + locale, + ); + + allErrors.push(...errors); + allWarnings.push(...warnings); + + additionalHtmlOutputFiles.set( + indexHtmlOptions.output, + createOutputFile(indexHtmlOptions.output, csrContent, BuildOutputFileType.Browser), + ); + + if (ssrContent) { + additionalHtmlOutputFiles.set( + INDEX_HTML_SERVER, + createOutputFile(INDEX_HTML_SERVER, ssrContent, BuildOutputFileType.ServerApplication), + ); + } + } + + // Create server manifest + const initialFilesPaths = new Set(initialFiles.keys()); + if (serverEntryPoint && (outputMode || prerenderOptions || appShellOptions || ssrOptions)) { + const { manifestContent, serverAssetsChunks } = await generateAngularServerAppManifest( + additionalHtmlOutputFiles, + outputFiles, + optimizationOptions.styles.inlineCritical ?? false, + undefined, + locale, + baseHref, + initialFilesPaths, + browserMetafile, + publicPath, + ); + + additionalOutputFiles.push( + ...serverAssetsChunks, + createOutputFile( + SERVER_APP_MANIFEST_FILENAME, + manifestContent, + BuildOutputFileType.ServerApplication, + ), + ); + } + + // Pre-render (SSG) and App-shell + // If localization is enabled, prerendering is handled in the inlining process. + if ( + !partialSSRBuild && + (prerenderOptions || appShellOptions || (outputMode && serverEntryPoint)) && + !allErrors.length + ) { + assert( + indexHtmlOptions, + 'The "index" option is required when using the "ssg" or "appShell" options.', + ); + + const { + output, + warnings, + errors, + serializableRouteTreeNode, + prerenderedRoutes: generatedPrerenderedRoutes, + } = await prerenderPages( + workspaceRoot, + baseHref, + appShellOptions, + prerenderOptions, + [...outputFiles, ...additionalOutputFiles], + assetFiles, + outputMode, + sourcemapOptions.scripts, + maxWorkers, + ); + + allErrors.push(...errors); + allWarnings.push(...warnings); + Object.assign(prerenderedRoutes, generatedPrerenderedRoutes); + + const indexHasBeenPrerendered = output[indexHtmlOptions.output]; + for (const [path, { content, appShellRoute }] of Object.entries(output)) { + // Update the index contents with the app shell under these conditions: + // - Replace 'index.html' with the app shell only if it hasn't been prerendered yet. + // - Always replace 'index.csr.html' with the app shell. + let filePath = path; + if (appShellRoute && !indexHasBeenPrerendered) { + if (outputMode !== OutputMode.Server && indexHtmlOptions.output === INDEX_HTML_CSR) { + filePath = 'index.html'; + } else { + filePath = indexHtmlOptions.output; + } + } + + additionalHtmlOutputFiles.set( + filePath, + createOutputFile(filePath, content, BuildOutputFileType.Browser), + ); + } + + const serializableRouteTreeNodeForManifest: WritableSerializableRouteTreeNode = []; + for (const metadata of serializableRouteTreeNode) { + serializableRouteTreeNodeForManifest.push(metadata); + } + + if (outputMode === OutputMode.Server) { + // Regenerate the manifest to append route tree. This is only needed if SSR is enabled. + const manifest = additionalOutputFiles.find((f) => f.path === SERVER_APP_MANIFEST_FILENAME); + assert(manifest, `${SERVER_APP_MANIFEST_FILENAME} was not found in output files.`); + + const { manifestContent, serverAssetsChunks } = await generateAngularServerAppManifest( + additionalHtmlOutputFiles, + outputFiles, + optimizationOptions.styles.inlineCritical ?? false, + serializableRouteTreeNodeForManifest, + locale, + baseHref, + initialFilesPaths, + browserMetafile, + publicPath, + ); + + for (const chunk of serverAssetsChunks) { + const idx = additionalOutputFiles.findIndex(({ path }) => path === chunk.path); + if (idx === -1) { + additionalOutputFiles.push(chunk); + } else { + additionalOutputFiles[idx] = chunk; + } + } + + manifest.contents = new TextEncoder().encode(manifestContent); + } + } + + additionalOutputFiles.push(...additionalHtmlOutputFiles.values()); + + // Augment the application with service worker support + // If localization is enabled, service worker is handled in the inlining process. + if (serviceWorker) { + try { + const serviceWorkerResult = await augmentAppWithServiceWorkerEsbuild( + workspaceRoot, + serviceWorker, + baseHref, + options.indexHtmlOptions?.output, + // Ensure additional files recently added are used + [...outputFiles, ...additionalOutputFiles], + assetFiles, + ); + + additionalOutputFiles.push( + createOutputFile('ngsw.json', serviceWorkerResult.manifest, BuildOutputFileType.Browser), + ); + additionalAssets.push(...serviceWorkerResult.assetFiles); + } catch (error) { + allErrors.push(error instanceof Error ? error.message : `${error}`); + } + } + + return { + errors: allErrors, + warnings: allWarnings, + additionalAssets, + prerenderedRoutes, + additionalOutputFiles, + }; +} diff --git a/packages/angular/build/src/builders/application/i18n.ts b/packages/angular/build/src/builders/application/i18n.ts new file mode 100644 index 000000000000..839384421f1f --- /dev/null +++ b/packages/angular/build/src/builders/application/i18n.ts @@ -0,0 +1,244 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderContext } from '@angular-devkit/architect'; +import type { Metafile } from 'esbuild'; +import assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + ExecutionResult, + PrerenderedRoutesRecord, +} from '../../tools/esbuild/bundler-execution-result'; +import { BuildOutputFileType, InitialFileRecord } from '../../tools/esbuild/bundler-files'; +import { I18nInliner } from '../../tools/i18n'; +import { maxInlinerWorkers } from '../../utils/environment-options'; +import { loadTranslations } from '../../utils/i18n-options'; +import { createTranslationLoader } from '../../utils/load-translations'; +import { createProjectResolver } from '../../utils/resolve-project'; +import type { WorkerPool } from '../../utils/worker-pool'; +import { executePostBundleSteps } from './execute-post-bundle'; +import { NormalizedApplicationBuildOptions, getLocaleBaseHref } from './options'; + +/** + * Inlines all active locales as specified by the application build options into all + * application JavaScript files created during the build. + * @param metafile An esbuild metafile object. + * @param options The normalized application builder options used to create the build. + * @param executionResult The result of an executed build. + * @param initialFiles A map containing initial file information for the executed build. + * @param workerPool An optional worker pool to use for running transformation tasks. + */ +export async function inlineI18n( + metafile: Metafile, + options: NormalizedApplicationBuildOptions, + executionResult: ExecutionResult, + initialFiles: Map, + workerPool?: WorkerPool, +): Promise<{ + errors: string[]; + warnings: string[]; + prerenderedRoutes: PrerenderedRoutesRecord; +}> { + const { i18nOptions, baseHref, cacheOptions } = options; + + // Create the multi-threaded inliner with common options. + const inliner = new I18nInliner( + { + missingTranslation: i18nOptions.missingTranslationBehavior ?? 'warning', + maxConcurrency: workerPool + ? Math.min(workerPool.maxThreads, maxInlinerWorkers) + : maxInlinerWorkers, + persistentCachePath: cacheOptions.enabled ? cacheOptions.path : undefined, + localizeVersion: i18nOptions.localizeVersion, + }, + workerPool, + ); + + const inlineResult: { + errors: string[]; + warnings: string[]; + prerenderedRoutes: PrerenderedRoutesRecord; + } = { + errors: [], + warnings: [], + prerenderedRoutes: {}, + }; + + // For each active locale, use the inliner to process the output files of the build. + const updatedOutputFiles = []; + const updatedAssetFiles = []; + // Root and SSR entry files are not modified. + const unModifiedOutputFiles = executionResult.outputFiles.filter( + ({ type }) => type === BuildOutputFileType.Root || type === BuildOutputFileType.ServerRoot, + ); + + try { + const localesToInline = Array.from(i18nOptions.inlineLocales, (locale) => { + const localeDescription = i18nOptions.locales[locale]; + let translationIntegrity: string | undefined = ''; + for (const file of localeDescription.files) { + if (!file.integrity) { + translationIntegrity = undefined; + break; + } + translationIntegrity += (translationIntegrity ? '|' : '') + file.integrity; + } + + return { + locale, + translation: localeDescription.translation, + translationIntegrity, + }; + }); + + const inlinedLocales = await inliner.inlineAll(executionResult.outputFiles, localesToInline); + + for (const locale of i18nOptions.inlineLocales) { + const localeInlineResult = inlinedLocales.get(locale); + assert(localeInlineResult !== undefined, 'Inlined result must exist for locale: ' + locale); + const localeOutputFiles = localeInlineResult.outputFiles; + inlineResult.errors.push(...localeInlineResult.errors); + inlineResult.warnings.push(...localeInlineResult.warnings); + + const { + errors, + warnings, + additionalAssets, + additionalOutputFiles, + prerenderedRoutes: generatedRoutes, + } = await executePostBundleSteps( + metafile, + { + ...options, + baseHref: getLocaleBaseHref(baseHref, i18nOptions, locale) ?? baseHref, + }, + [...unModifiedOutputFiles, ...localeOutputFiles], + executionResult.assetFiles, + initialFiles, + locale, + ); + + localeOutputFiles.push(...additionalOutputFiles); + inlineResult.errors.push(...errors); + inlineResult.warnings.push(...warnings); + + // Update directory with locale base or subPath + const subPath = i18nOptions.locales[locale].subPath; + if (i18nOptions.flatOutput !== true) { + localeOutputFiles.forEach((file) => { + file.path = join(subPath, file.path); + }); + + for (const assetFile of [...executionResult.assetFiles, ...additionalAssets]) { + updatedAssetFiles.push({ + source: assetFile.source, + destination: join(subPath, assetFile.destination), + }); + } + } else { + executionResult.assetFiles.push(...additionalAssets); + } + + inlineResult.prerenderedRoutes = { ...inlineResult.prerenderedRoutes, ...generatedRoutes }; + updatedOutputFiles.push(...localeOutputFiles); + } + + // Update the result with all localized files. + executionResult.outputFiles = [ + // Root and SSR entry files are not modified. + ...unModifiedOutputFiles, + // Updated files for each locale. + ...updatedOutputFiles, + ]; + + // Assets are only changed if not using the flat output option + if (!i18nOptions.flatOutput) { + executionResult.assetFiles = updatedAssetFiles; + } + + // Inline any template updates if present + if (executionResult.templateUpdates?.size) { + // The development server only allows a single locale but issue a warning if used programmatically (experimental) + // with multiple locales and template HMR. + if (localesToInline.length > 1) { + inlineResult.warnings.push( + `Component HMR updates can only be inlined with a single locale. The first locale will be used.`, + ); + } + const targetLocale = localesToInline[0]; + + for (const [id, content] of executionResult.templateUpdates) { + const templateUpdateResult = await inliner.inlineTemplateUpdate( + targetLocale.locale, + targetLocale.translation, + content, + id, + targetLocale.translationIntegrity, + ); + executionResult.templateUpdates.set(id, templateUpdateResult.code); + inlineResult.errors.push(...templateUpdateResult.errors); + inlineResult.warnings.push(...templateUpdateResult.warnings); + } + } + + return inlineResult; + } finally { + await inliner.close(); + } +} + +/** + * Loads all active translations using the translation loaders from the `@angular/localize` package. + * @param context The architect builder context for the current build. + * @param i18n The normalized i18n options to use. + */ +export async function loadActiveTranslations( + context: BuilderContext, + i18n: NormalizedApplicationBuildOptions['i18nOptions'], +) { + if (!i18n.localizeVersion) { + try { + const projectResolve = createProjectResolver(context.workspaceRoot); + const manifestPath = projectResolve('@angular/localize/package.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf-8')) as { version?: string }; + i18n.localizeVersion = manifest.version; + } catch {} + } + + // Load locale data and translations (if present) + let loader; + for (const [locale, desc] of Object.entries(i18n.locales)) { + if (!i18n.inlineLocales.has(locale) && locale !== i18n.sourceLocale) { + continue; + } + + if (!desc.files.length) { + continue; + } + + loader ??= await createTranslationLoader(); + + loadTranslations( + locale, + desc, + context.workspaceRoot, + loader, + { + warn(message) { + context.logger.warn(message); + }, + error(message) { + throw new Error(message); + }, + }, + undefined, + i18n.duplicateTranslationBehavior, + ); + } +} diff --git a/packages/angular/build/src/builders/application/index-html-generator.ts b/packages/angular/build/src/builders/application/index-html-generator.ts new file mode 100644 index 000000000000..a1c9cbcc9b30 --- /dev/null +++ b/packages/angular/build/src/builders/application/index-html-generator.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { + BuildOutputFile, + BuildOutputFileType, + InitialFileRecord, +} from '../../tools/esbuild/bundler-files'; +import { IndexHtmlGenerator } from '../../utils/index-file/index-html-generator'; +import { NormalizedApplicationBuildOptions } from './options'; + +/** + * The maximum number of module preload link elements that should be added for + * initial scripts. + */ +const MODULE_PRELOAD_MAX = 10; + +export async function generateIndexHtml( + initialFiles: Map, + outputFiles: BuildOutputFile[], + buildOptions: NormalizedApplicationBuildOptions, + lang?: string, +): Promise<{ + csrContent: string; + ssrContent?: string; + warnings: string[]; + errors: string[]; +}> { + // Analyze metafile for initial link-based hints. + // Skip if the internal externalPackages option is enabled since this option requires + // dev server cooperation to properly resolve and fetch imports. + const hints = []; + const { + indexHtmlOptions, + externalPackages, + optimizationOptions, + crossOrigin, + subresourceIntegrity, + baseHref, + } = buildOptions; + + assert(indexHtmlOptions, 'indexHtmlOptions cannot be undefined.'); + + if (!externalPackages && indexHtmlOptions.preloadInitial) { + const modulePreloads = []; + for (const [key, value] of initialFiles) { + if (value.entrypoint || value.serverFile) { + // Entry points are already referenced in the HTML + continue; + } + + if (value.type === 'script') { + modulePreloads.push({ url: key, mode: 'modulepreload' as const, depth: value.depth }); + } else if (value.type === 'style') { + // Provide an "as" value of "style" to ensure external URLs which may not have a + // file extension are treated as stylesheets. + hints.push({ url: key, mode: 'preload' as const, as: 'style' }); + } + } + + // Limit the number of module preloads with smallest depth given priority + modulePreloads.sort((a, b) => a.depth - b.depth); + hints.push(...modulePreloads.slice(0, MODULE_PRELOAD_MAX)); + } + + /** Virtual output path to support reading in-memory files. */ + const browserOutputFiles = outputFiles.filter(({ type }) => type === BuildOutputFileType.Browser); + const virtualOutputPath = '/'; + const readAsset = async function (filePath: string): Promise { + // Remove leading directory separator + const relativefilePath = path.relative(virtualOutputPath, filePath); + const file = browserOutputFiles.find((file) => file.path === relativefilePath); + if (file) { + return file.text; + } + + throw new Error(`Output file does not exist: ${relativefilePath}`); + }; + + // When SRI is enabled, build an integrity map for every browser JavaScript + // chunk so an `', + ); + indexFileContent.toContain(' { + describe('Behavior: "Esbuild for-await"', () => { + it('should properly downlevel for-await loops with optimization enabled', async () => { + // Setup a for-await loop that triggers the esbuild minification bug when async/await is downleveled. + await harness.writeFile( + 'src/main.ts', + ` + async function test() { + const someAsyncIterable = { + [Symbol.asyncIterator]() { + return { + next() { + return Promise.resolve({ done: true, value: undefined }); + } + }; + } + }; + for await(const item of someAsyncIterable) { + console.log(item); + } + } + test(); + `, + ); + + // Ensure target is ES2022 so that optional catch binding is supported natively. + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.compilerOptions.target = 'ES2022'; + return JSON.stringify(tsConfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + polyfills: ['zone.js'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + // We expect the output to contain a catch block that captures the error in a variable, + // even if that variable is mangled. + // The pattern for the downleveled for-await catch block is roughly: + // } catch (temp) { error = [temp]; } + // + // With the bug, esbuild (when minifying) would optimize away the catch binding if it thought it was unused, + // resulting in: } catch { ... } which breaks the logic requiring the error object. + // + // The regex matches: + // catch \s* -> catch keyword and whitespace + // \( [a-zA-Z_$][\w$]* \) -> (variable) + // \s* { \s* -> { and whitespace + // [a-zA-Z_$][\w$]* -> error array variable + // \s* = \s* -> assignment + // \[ [a-zA-Z_$][\w$]* \] -> [variable] + harness + .expectFile('dist/browser/main.js') + .content.toMatch( + /catch\s*\([a-zA-Z_$][\w$]*\)\s*\{\s*[a-zA-Z_$][\w$]*\s*=\s*\[[a-zA-Z_$][\w$]*\]/, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/index-csr_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/index-csr_spec.ts new file mode 100644 index 000000000000..0f87dd78a289 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/index-csr_spec.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "index.csr.html"', () => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts'); + + return JSON.stringify(tsConfig); + }); + }); + + it(`should generate 'index.csr.html' instead of 'index.html' when ssr is enabled.`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectDirectory('dist/server').toExist(); + harness.expectFile('dist/browser/index.csr.html').toExist(); + harness.expectFile('dist/browser/index.html').toNotExist(); + }); + + it(`should generate 'index.csr.html' instead of 'index.html' when 'output' is 'index.html' and ssr is enabled.`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: { + input: 'src/index.html', + output: 'index.html', + }, + server: 'src/main.server.ts', + ssr: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectDirectory('dist/server').toExist(); + harness.expectFile('dist/browser/index.csr.html').toExist(); + harness.expectFile('dist/browser/index.html').toNotExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/index-preload-hints_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/index-preload-hints_spec.ts new file mode 100644 index 000000000000..7f6b9711790b --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/index-preload-hints_spec.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Preload hints"', () => { + it('should add preload hints for transitive global style imports', async () => { + await harness.writeFile( + 'src/styles.css', + ` + @import url('https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@300;400;500;700&display=swap'); + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + harness + .expectFile('dist/browser/index.html') + .content.toContain( + '', + ); + }); + + it('should not add preload hints for ssr files', async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts'); + + return JSON.stringify(tsConfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/main.server.mjs').toExist(); + + harness + .expectFile('dist/browser/index.csr.html') + .content.not.toMatch(//); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/loader-import-attribute_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/loader-import-attribute_spec.ts new file mode 100644 index 000000000000..91c4cafc571a --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/loader-import-attribute_spec.ts @@ -0,0 +1,183 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "loader import attribute"', () => { + beforeEach(async () => { + await harness.modifyFile('tsconfig.json', (content) => { + return content.replace('"module": "ES2022"', '"module": "esnext"'); + }); + }); + + it('should inline text content for loader attribute set to "text"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "text" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('ABC'); + }); + + it('should inline binary content for loader attribute set to "binary"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "binary" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + // Should contain the binary encoding used esbuild and not the text content + harness.expectFile('dist/browser/main.js').content.toContain('__toBinary("QUJD")'); + harness.expectFile('dist/browser/main.js').content.not.toContain('ABC'); + }); + + it('should inline base64 content for file extension set to "base64"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "base64" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + // Should contain the base64 encoding used esbuild and not the text content + harness.expectFile('dist/browser/main.js').content.toContain('QUJD'); + harness.expectFile('dist/browser/main.js').content.not.toContain('ABC'); + }); + + it('should inline dataurl content for file extension set to "dataurl"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.svg', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.svg" with { loader: "dataurl" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + // Should contain the dataurl encoding used esbuild and not the text content + harness.expectFile('dist/browser/main.js').content.toContain('data:image/svg+xml,ABC'); + }); + + it('should emit an output file for loader attribute set to "file"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "file" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.unknown'); + harness.expectFile('dist/browser/media/a.unknown').toExist(); + }); + + it('should emit an output file with hashing when enabled for loader attribute set to "file"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + outputHashing: 'media' as any, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "file" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.unknown'); + expect(harness.hasFileMatch('dist/browser/media', /a-[0-9A-Z]{8}\.unknown$/)).toBeTrue(); + }); + + it('should allow overriding default `.txt` extension behavior', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.txt', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.txt" with { loader: "file" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.txt'); + harness.expectFile('dist/browser/media/a.txt').toExist(); + }); + + it('should allow overriding default `.js` extension behavior', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.js', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.js" with { loader: "file" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.js'); + harness.expectFile('dist/browser/media/a.js').toExist(); + }); + + it('should fail with an error if an invalid loader attribute value is used', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "invalid" };\n console.log(contents);', + ); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Unsupported loader import attribute'), + }), + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/platform-server-domino_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/platform-server-domino_spec.ts new file mode 100644 index 000000000000..df58f1c833c0 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/platform-server-domino_spec.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { OutputMode, Platform } from '../../schema'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * `@angular/platform-server/init` (bundled into the server polyfills) and + * `@angular/platform-server` (bundled into the main server bundle) both import this file. + */ +const DOMINO_BUNDLE = 'third_party/domino/bundled-domino.mjs'; + +/** The property on globalThis where the Domino instance is stored. */ +const DOMINO_GLOBAL_PROPERTY = /(?:ɵ|\\u0275)domino/; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Domino is bundled once for the server"', () => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts', 'server.ts'); + + return JSON.stringify(tsConfig); + }); + + await harness.writeFile('src/server.ts', `console.log('Hello!');`); + + // The default template uses i18n which requires `@angular/localize` to prerender. + await harness.writeFile('src/app/app.component.html', `

Hello!

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

{{ value }}

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

{{ value }}

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

{{ label() }}

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

{{ label() }}

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

{{ label() }}

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

GOOD

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

Worker Test

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

Hello

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

Worker Test

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

Worker Test

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

app-shell works!

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

Initial Template

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

Updated Template

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

Primary Initial

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

Secondary Initial

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

Secondary Updated

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

{{ title }}

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

hello

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

hello

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

hello

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

hello

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

lib

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

foo

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

other

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

Hello, {{ title }}

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

Hello, {{ title }}

', + standalone: false, + }) + export class App { + title = 'test'; + } + `, + 'src/app/app-module.ts': ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { RouterModule } from '@angular/router'; + import { App } from './app'; + + @NgModule({ + declarations: [App], + imports: [BrowserModule, RouterModule.forRoot([])], + bootstrap: [App], + }) + export class AppModule {} + `, + 'src/app/app.spec.ts': ` + import { TestBed } from '@angular/core/testing'; + import { App } from './app'; + import { AppModule } from './app-module'; + + describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppModule], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + expect(fixture.componentInstance).toBeTruthy(); + }); + }); + `, + 'src/app/unused-broken.ts': ` + // This unimported file has a TypeScript error and should not be compiled when AppModule is already imported + export const invalidNumber: number = 'not a number'; + `, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + include: ['src/app/app.spec.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/isolate_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/isolate_spec.ts new file mode 100644 index 000000000000..28fbb855a91f --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/isolate_spec.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, + expectLog, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "isolate"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should fail when isolate is true and runner is karma', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runner: 'karma' as any, + isolate: true, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + expectLog(logs, /The "isolate" option is only available for the "vitest" runner/); + }); + + it('should run tests successfully when isolate is true and runner is vitest', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runner: 'vitest' as any, + isolate: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should run tests successfully when isolate is false and runner is vitest', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runner: 'vitest' as any, + isolate: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should override isolate from the Vitest config file when set to false', async () => { + harness.writeFile( + 'vitest-base.config.mts', + ` + import { defineConfig } from 'vitest/config'; + + export default defineConfig({ + test: { + fileParallelism: false, + isolate: true, + setupFiles: ['./src/setup.ts'], + }, + }); + `, + ); + + harness.writeFile( + 'src/setup.ts', + ` + const global = globalThis as typeof globalThis & { setupCount?: number }; + global.setupCount = (global.setupCount ?? 0) + 1; + `, + ); + + harness.writeFile( + 'src/app/first.spec.ts', + ` + it('runs first', () => { + expect(true).toBe(true); + }); + `, + ); + + harness.writeFile( + 'src/app/second.spec.ts', + ` + it('shares the test environment with the first spec file', () => { + const global = globalThis as typeof globalThis & { setupCount?: number }; + expect(global.setupCount).toBeGreaterThan(1); + }); + `, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + runner: 'vitest' as any, + runnerConfig: true, + isolate: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/list-tests_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/list-tests_spec.ts new file mode 100644 index 000000000000..ade399d1936f --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/list-tests_spec.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "listTests"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + + await harness.writeFiles({ + 'src/app/app.component.spec.ts': + 'describe("AppComponent", () => { it("should...", () => {}); });', + 'src/app/other.spec.ts': 'describe("Other", () => { it("should...", () => {}); });', + 'src/app/ignored.ts': 'export const a = 1;', + }); + }); + + it('should list all discovered tests and exit when true', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + listTests: true, + }); + + const { result, logs } = await harness.executeOnce(); + + // Should succeed and exit without running tests + expect(result?.success).toBe(true); + + // Should log the discovered test files + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/Discovered test files:/) }), + ); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/.*app\.component\.spec\.ts/), + }), + ); + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/.*other\.spec\.ts/) }), + ); + expect(logs).not.toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/.*ignore\.ts/) }), + ); + + // Should NOT log output from the test runner (since it shouldn't run) + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Application bundle generation complete/), + }), + ); + }); + + it('should not list tests and should run them as normal when false', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + listTests: false, + }); + + const { result, logs } = await harness.executeOnce(); + + // Should succeed because the tests pass + expect(result?.success).toBe(true); + + // Should NOT log the discovered test files + expect(logs).not.toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/Discovered test files:/) }), + ); + + // Should log output from the test runner + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Application bundle generation complete/), + }), + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/polyfills_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/polyfills_spec.ts new file mode 100644 index 000000000000..06bc3bd8fc98 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/polyfills_spec.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "polyfills"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should include polyfills specified on test target', async () => { + await harness.writeFiles({ + 'src/custom-polyfill.js': `globalThis['CUSTOM_POLYFILL_RAN'] = true;`, + 'src/app/app.component.spec.ts': ` + import { describe, expect, test } from 'vitest'; + describe('Polyfill Test', () => { + test('should have run test polyfill', () => { + expect((globalThis as any)['CUSTOM_POLYFILL_RAN']).toBe(true); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + polyfills: ['src/custom-polyfill.js'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should override buildTarget polyfills when polyfills is specified on test target', async () => { + setupApplicationTarget(harness, { + polyfills: ['src/app-polyfill.js'], + }); + + await harness.writeFiles({ + 'src/app-polyfill.js': `globalThis['APP_POLYFILL_RAN'] = true;`, + 'src/test-polyfill.js': `globalThis['TEST_POLYFILL_RAN'] = true;`, + 'src/app/app.component.spec.ts': ` + import { describe, expect, test } from 'vitest'; + describe('Polyfill Override Test', () => { + test('should have run test polyfill and not app polyfill', () => { + expect((globalThis as any)['TEST_POLYFILL_RAN']).toBe(true); + expect((globalThis as any)['APP_POLYFILL_RAN']).toBeUndefined(); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + polyfills: ['src/test-polyfill.js'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should allow overriding buildTarget polyfills with an empty array', async () => { + setupApplicationTarget(harness, { + polyfills: ['src/app-polyfill.js'], + }); + + await harness.writeFiles({ + 'src/app-polyfill.js': `globalThis['APP_POLYFILL_RAN'] = true;`, + 'src/app/app.component.spec.ts': ` + import { describe, expect, test } from 'vitest'; + describe('Empty Polyfill Override Test', () => { + test('should not have run app polyfill', () => { + expect((globalThis as any)['APP_POLYFILL_RAN']).toBeUndefined(); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + polyfills: [], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/providers-file_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/providers-file_spec.ts new file mode 100644 index 000000000000..6485975270e8 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/providers-file_spec.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "providersFile"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should fail when providersFile does not exist', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + providersFile: 'src/my.providers.ts', + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Could not resolve "./my.providers"'), + }), + ); + }); + + it('should use providers from the specified file', async () => { + await harness.writeFiles({ + 'src/my.providers.ts': ` + import { importProvidersFrom } from '@angular/core'; + import { CommonModule } from '@angular/common'; + export default [importProvidersFrom(CommonModule)]; + `, + }); + + await harness.modifyFile('src/tsconfig.spec.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('my.providers.ts'); + + return JSON.stringify(tsConfig); + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + providersFile: 'src/my.providers.ts', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should work with providers that inject services requiring JIT compilation', async () => { + await harness.writeFiles({ + 'src/test.service.ts': ` + import { Injectable, inject } from '@angular/core'; + import { Router } from '@angular/router'; + + @Injectable({ providedIn: 'root' }) + export class TestService { + router = inject(Router); + } + `, + 'src/test.providers.ts': ` + import { TestService } from './test.service'; + export default [TestService]; + `, + 'src/app/app.component.spec.ts': ` + import { TestBed } from '@angular/core/testing'; + import { AppComponent } from './app.component'; + import { TestService } from '../test.service'; + import { describe, expect, it } from 'vitest'; + + describe('AppComponent', () => { + it('should create the app and inject TestService', () => { + TestBed.configureTestingModule({ + declarations: [AppComponent], + }); + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + const testService = TestBed.inject(TestService); + expect(app).toBeTruthy(); + expect(testService).toBeTruthy(); + expect(testService.router).toBeTruthy(); + }); + }); + `, + }); + + await harness.modifyFile('src/tsconfig.spec.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('test.service.ts', 'test.providers.ts'); + + return JSON.stringify(tsConfig); + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + providersFile: 'src/test.providers.ts', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/quiet_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/quiet_spec.ts new file mode 100644 index 000000000000..1ca8281db8b1 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/quiet_spec.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, + expectLog, + expectNoLog, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "quiet"', () => { + let originalCI: string | undefined; + + beforeEach(async () => { + setupApplicationTarget(harness); + originalCI = process.env['CI']; + }); + + afterEach(() => { + if (originalCI !== undefined) { + process.env['CI'] = originalCI; + } else { + delete process.env['CI']; + } + }); + + it('should default to true (quiet) when CI is not set', async () => { + delete process.env['CI']; + + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + // Should not contain the stats table headers + expectNoLog(logs, /Initial chunk files/); + }); + + it('should default to false (verbose) when CI is set', async () => { + process.env['CI'] = 'true'; + + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + // Should contain the stats table headers or file listing + expectLog(logs, /Application bundle generation complete/); + }); + + it('should respect quiet: true explicitly', async () => { + process.env['CI'] = 'false'; // Ensure CI doesn't interfere if it defaults to false + + harness.useTarget('test', { + ...BASE_OPTIONS, + quiet: true, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expectNoLog(logs, /Initial chunk files/); + }); + + it('should respect quiet: false explicitly', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + quiet: false, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + // On initial build, it should print the file list + expectLog(logs, /Initial/); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/reporter_and_output_file_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/reporter_and_output_file_spec.ts new file mode 100644 index 000000000000..31fc1a7bfe95 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/reporter_and_output_file_spec.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Options: "reporter" and "outputFile"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it(`should output a JSON report`, async () => { + await harness.removeFile('src/app/app.component.spec.ts'); + await harness.writeFiles({ + 'src/app/services/test.service.spec.ts': ` + describe('TestService', () => { + it('should succeed', () => { + expect(true).toBe(true); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + reporters: ['json'], + outputFile: 'test-report.json', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + const reportContent = await harness.readFile('test-report.json'); + expect(reportContent).toContain('TestService'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/reporters_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/reporters_spec.ts new file mode 100644 index 000000000000..89ac5804e37e --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/reporters_spec.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "reporters"', () => { + beforeEach(() => { + setupApplicationTarget(harness); + }); + + it(`should support a single reporter`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + reporters: ['json'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it(`should support multiple reporters`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + reporters: ['json', 'verbose'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it(`should support a single reporter with options`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + reporters: [['json', { outputFile: 'a.json' }]], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('a.json').toExist(); + }); + + it(`should support multiple reporters with options`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + reporters: [ + ['json', { outputFile: 'a.json' }], + ['junit', { outputFile: 'a.xml' }], + ], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('a.json').toExist(); + harness.expectFile('a.xml').toExist(); + }); + + it(`should support multiple reporters with and without options`, async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + reporters: [['json', { outputFile: 'a.json' }], 'verbose', 'default'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('a.json').toExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/runner-config-coverage_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/runner-config-coverage_spec.ts new file mode 100644 index 000000000000..9998caea72e3 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/runner-config-coverage_spec.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "runnerConfig" Coverage Merging', () => { + beforeEach(() => { + setupApplicationTarget(harness); + }); + + describe('Vitest Runner', () => { + it('should preserve thresholds from Vitest config when not overridden by CLI', async () => { + harness.writeFile( + 'vitest-base.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + coverage: { + thresholds: { + branches: 100 + } + } + } + }); + `, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: true, + coverage: true, + }); + + const { result } = await harness.executeOnce(); + + // Should fail because branches are not 100% + expect(result?.success).toBeFalse(); + }); + + it('should override Vitest config thresholds with CLI thresholds', async () => { + harness.writeFile( + 'vitest-base.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + coverage: { + thresholds: { + branches: 100 + } + } + } + }); + `, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: true, + coverage: true, + coverageThresholds: { + branches: 0, + }, + }); + + const { result } = await harness.executeOnce(); + + // Should pass because CLI overrides threshold to 0 + expect(result?.success).toBeTrue(); + }); + + it('should merge partial CLI thresholds with Vitest config thresholds', async () => { + harness.writeFile( + 'vitest-base.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + coverage: { + thresholds: { + statements: 100, + branches: 100 + } + } + } + }); + `, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: true, + coverage: true, + coverageThresholds: { + statements: 0, + // branches is undefined here, should remain 100 from config + }, + }); + + const { result } = await harness.executeOnce(); + + // Should still fail because branches threshold (100) is not met + expect(result?.success).toBeFalse(); + }); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/runner-config_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/runner-config_spec.ts new file mode 100644 index 000000000000..57ec6be0f25d --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/runner-config_spec.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +const VITEST_CONFIG_CONTENT = ` +import { defineConfig } from 'vitest/config'; +export default defineConfig({ + test: { + reporters: [['junit', { outputFile: './vitest-results.xml' }]], + }, +}); +`; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "runnerConfig"', () => { + beforeEach(() => { + setupApplicationTarget(harness); + }); + + describe('Vitest Runner', () => { + it('should use a specified config file path', async () => { + harness.writeFile('custom-vitest.config.mts', VITEST_CONFIG_CONTENT); + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'custom-vitest.config.mts', + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toExist(); + }); + + it('should search for a config file when `true`', async () => { + harness.writeFile('vitest-base.config.mts', VITEST_CONFIG_CONTENT); + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toExist(); + }); + + it('should ignore config file when `false`', async () => { + harness.writeFile('vitest-base.config.mts', VITEST_CONFIG_CONTENT); + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: false, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toNotExist(); + }); + + it('should ignore config file by default', async () => { + harness.writeFile('vitest-base.config.mts', VITEST_CONFIG_CONTENT); + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toNotExist(); + }); + + it('should find and use a `vitest-base.config.mts` in the project root', async () => { + harness.writeFile('vitest-base.config.mts', VITEST_CONFIG_CONTENT); + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toExist(); + }); + + it('should find and use a `vitest-base.config.mjs` in the workspace root', async () => { + // This file should be ignored because the new logic looks for `vitest-base.config.*`. + harness.writeFile('vitest.config.ts', VITEST_CONFIG_CONTENT); + // The workspace root is the directory containing the project root in the test harness. + harness.writeFile('vitest-base.config.mjs', VITEST_CONFIG_CONTENT); + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toExist(); + }); + + it('should fallback to in-memory config when no base config is found', async () => { + // This file should be ignored because the new logic looks for `vitest-base.config.*` + // and when `runnerConfig` is true, it should not fall back to the default search. + harness.writeFile('vitest.config.mts', VITEST_CONFIG_CONTENT); + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toNotExist(); + }); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/runner_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/runner_spec.ts new file mode 100644 index 000000000000..77479149784c --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/runner_spec.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + xdescribe('Option: "runner"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should fail when runner is not provided', async () => { + const { runner, ...rest } = BASE_OPTIONS; + harness.useTarget('test', rest as any); + + await expectAsync(harness.executeOnce()).toBeRejectedWithError(/"runner" is required/); + }); + + it('should fail when runner is invalid', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + runner: 'invalid' as any, + }); + + await expectAsync(harness.executeOnce()).toBeRejectedWithError( + /must be one of the following values: "karma", "vitest"/, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts new file mode 100644 index 000000000000..44241bbcfd8a --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts @@ -0,0 +1,121 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, + expectLog, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "setupFiles"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should fail when a setup file does not exist', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + setupFiles: ['src/setup.ts'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + // Verify that the build failed due to resolution error (esbuild error) + expectLog(logs, /Could not resolve/); + expectLog(logs, /src\/setup\.ts/); + }); + + it('should include the setup files', async () => { + await harness.writeFiles({ + 'src/setup.ts': `(globalThis as any)['TEST_SETUP_RAN'] = true;`, + 'src/app/app.component.spec.ts': ` + import { describe, expect, test } from 'vitest' + describe('AppComponent', () => { + test('should have run setup file', () => { + expect((globalThis as any)['TEST_SETUP_RAN']).toBe(true); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + setupFiles: ['src/setup.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should run setup file hooks for each spec file when coverage is enabled', async () => { + await harness.writeFiles({ + 'custom-vitest.config.mts': ` + import { defineConfig } from 'vitest/config'; + + export default defineConfig({ + test: { + fileParallelism: false, + }, + }); + `, + 'src/setup.ts': ` + import { afterEach, beforeEach, expect } from 'vitest'; + const global = globalThis as typeof globalThis & { + setupHookCalls?: string[]; + }; + const setupHookCalls = (global.setupHookCalls ??= []); + beforeEach(() => { + const testName = expect.getState().currentTestName ?? ''; + setupHookCalls.push('beforeEach:' + testName); + }); + afterEach(() => { + const testName = expect.getState().currentTestName ?? ''; + setupHookCalls.push('afterEach:' + testName); + }); + `, + 'src/app/app.component.spec.ts': ` + import { expect, it } from 'vitest'; + it('runs setup hooks for first test in app.component.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for first test in app.component.spec'); + }); + it('runs setup hooks for second test in app.component.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for second test in app.component.spec'); + expect(global.setupHookCalls).toContain('afterEach:runs setup hooks for first test in app.component.spec'); + }); + `, + 'src/app/second.spec.ts': ` + import { expect, it } from 'vitest'; + it('runs setup hooks for first test in second.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for first test in second.spec'); + }); + it('runs setup hooks for second test in second.spec', () => { + const global = globalThis as typeof globalThis & { setupHookCalls?: string[] }; + expect(global.setupHookCalls).toContain('beforeEach:runs setup hooks for second test in second.spec'); + expect(global.setupHookCalls).toContain('afterEach:runs setup hooks for first test in second.spec'); + }); + `, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + coverage: true, + runnerConfig: 'custom-vitest.config.mts', + setupFiles: ['src/setup.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/splitting_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/splitting_spec.ts new file mode 100644 index 000000000000..133c54ca21e9 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/splitting_spec.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, + expectLog, + expectNoLog, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "splitting"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should default to true and run tests successfully', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should run tests successfully when splitting is true', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + splitting: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should run tests successfully when splitting is false', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + splitting: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should split shared code into a separate chunk when splitting is true', async () => { + await harness.writeFile( + 'src/app/shared.ts', + `export const SHARED_DATA = 'shared-data-value';`, + ); + + await harness.writeFile( + 'src/app/first.spec.ts', + ` + import { SHARED_DATA } from './shared'; + + it('tests first', () => { + expect(SHARED_DATA).toBe('shared-data-value'); + }); + `, + ); + + await harness.writeFile( + 'src/app/second.spec.ts', + ` + import { SHARED_DATA } from './shared'; + + it('tests second', () => { + expect(SHARED_DATA).toBe('shared-data-value'); + }); + `, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + splitting: true, + quiet: false, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expectLog(logs, /chunk-[a-z0-9]+\.js/i); + }); + + it('should not split shared code into a separate chunk when splitting is false', async () => { + await harness.writeFile( + 'src/app/shared.ts', + `export const SHARED_DATA = 'shared-data-value';`, + ); + + await harness.writeFile( + 'src/app/first.spec.ts', + ` + import { SHARED_DATA } from './shared'; + + it('tests first', () => { + expect(SHARED_DATA).toBe('shared-data-value'); + }); + `, + ); + + await harness.writeFile( + 'src/app/second.spec.ts', + ` + import { SHARED_DATA } from './shared'; + + it('tests second', () => { + expect(SHARED_DATA).toBe('shared-data-value'); + }); + `, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + splitting: false, + quiet: false, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expectNoLog(logs, /chunk-[a-z0-9]+\.js/i); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/tsconfig_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/tsconfig_spec.ts new file mode 100644 index 000000000000..101f07c1d12e --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/tsconfig_spec.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, + expectLog, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "tsConfig"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should use "tsconfig.spec.json" by default when it exists', async () => { + const { tsConfig, ...rest } = BASE_OPTIONS; + harness.useTarget('test', rest); + + // Create tsconfig.spec.json + await harness.writeFile( + 'tsconfig.spec.json', + `{ "extends": "./tsconfig.json", "compilerOptions": { "types": ["jasmine"] }, "include": ["src/**/*.ts"] }`, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + // TODO: Add expectation that the file was used. + }); + + it('should use build target tsConfig when "tsconfig.spec.json" does not exist', async () => { + const { tsConfig, ...rest } = BASE_OPTIONS; + harness.useTarget('test', rest); + + // The build target tsconfig is not setup to build the tests and should fail + const { result } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + }); + + it('should fail when user specified tsConfig does not exist', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + tsConfig: 'random/tsconfig.spec.json', + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expectLog(logs, `The specified tsConfig file 'random/tsconfig.spec.json' does not exist.`); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/options/watch_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/watch_spec.ts new file mode 100644 index 000000000000..79e25ca84b27 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/watch_spec.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "watch"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should run tests once when watch is false', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + watch: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should re-run tests when a file changes when watch is true', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + await harness.writeFiles({ + 'src/app/app.component.spec.ts': ` + import { describe, expect, test } from 'vitest' + describe('AppComponent', () => { + test('should create the app', () => { + expect(true).toBe(false); + }); + });`, + }); + }, + ({ result }) => { + expect(result?.success).toBeFalse(); + }, + ]); + }); + + it('should disable the Vite server file watcher when watch is true', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + watch: true, + runnerConfig: 'vitest.config.mts', + }); + + await harness.writeFile( + 'vitest.config.mts', + ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + plugins: [ + { + name: 'assert-watch-disabled', + configureVitest({ vitest, project }) { + if (vitest.vite.config.server.watch !== null || project.vite.config.server.watch !== null) { + throw new Error('Expected Vite server.watch to be null'); + } + }, + }, + ], + }); + `, + ); + + await harness.executeWithCases([ + ({ result }) => { + expect(result?.success).toBeTrue(); + }, + ]); + }); + }); +}); diff --git a/packages/angular/build/src/builders/unit-test/tests/setup.ts b/packages/angular/build/src/builders/unit-test/tests/setup.ts new file mode 100644 index 000000000000..e6770e115789 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/setup.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { json } from '@angular-devkit/core'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { BuilderHarness } from '../../../../../../../modules/testing/builder/src'; +import { + ApplicationBuilderOptions as ApplicationSchema, + buildApplication, +} from '../../../builders/application'; +import { Runner, Schema } from '../schema'; + +// TODO: Consider using package.json imports field instead of relative path +// after the switch to rules_js. +export * from '../../../../../../../modules/testing/builder/src'; + +// TODO: Remove and use import after Vite-based dev server is moved to new package +export const APPLICATION_BUILDER_INFO = Object.freeze({ + name: '@angular/build:application', + schemaPath: path.join( + path.dirname(require.resolve('@angular/build/package.json')), + 'src/builders/application/schema.json', + ), +}); + +/** + * Contains all required application builder fields. + * Also disables progress reporting to minimize logging output. + */ +export const APPLICATION_BASE_OPTIONS = Object.freeze({ + index: 'src/index.html', + browser: 'src/main.ts', + outputPath: 'dist', + tsConfig: 'src/tsconfig.app.json', + progress: false, + + // Disable optimizations + optimization: false, + + // Enable polling (if a test enables watch mode). + // This is a workaround for bazel isolation file watch not triggering in tests. + poll: 100, +}); + +export const UNIT_TEST_BUILDER_INFO = Object.freeze({ + name: '@angular/build:unit-test', + schemaPath: __dirname + '/../schema.json', +}); + +/** + * Contains all required dev-server builder fields. + * The port is also set to zero to ensure a free port is used for each test which + * supports parallel test execution. + */ +export const BASE_OPTIONS = Object.freeze({ + buildTarget: 'test:build', + tsConfig: 'src/tsconfig.spec.json', + runner: Runner.Vitest, +}); + +/** + * Maximum time for single build/rebuild + * This accounts for CI variability. + */ +export const BUILD_TIMEOUT = 25_000; + +/** + * Cached application builder option schema + */ +let applicationSchema: json.schema.JsonSchema | undefined; + +/** + * Adds a `build` target to a builder test harness for the application builder with the base options + * used by the application builder tests. + * + * @param harness The builder harness to use when setting up the application builder target + * @param extraOptions The additional options that should be used when executing the target. + */ +export function setupApplicationTarget( + harness: BuilderHarness, + extraOptions?: Partial, +): void { + applicationSchema ??= JSON.parse( + readFileSync(APPLICATION_BUILDER_INFO.schemaPath, 'utf8'), + ) as json.schema.JsonSchema; + + harness.withBuilderTarget( + 'build', + buildApplication, + { + ...APPLICATION_BASE_OPTIONS, + polyfills: ['zone.js', '@angular/localize/init'], + ...extraOptions, + }, + { + builderName: APPLICATION_BUILDER_INFO.name, + optionSchema: applicationSchema, + }, + ); +} diff --git a/packages/angular/build/src/index.ts b/packages/angular/build/src/index.ts new file mode 100644 index 000000000000..b6a547921db8 --- /dev/null +++ b/packages/angular/build/src/index.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export { buildApplication, type ApplicationBuilderOptions } from './builders/application'; +export type { ApplicationBuilderExtensions } from './builders/application/options'; +export { type BuildOutputFile, BuildOutputFileType } from './tools/esbuild/bundler-files'; +export type { BuildOutputAsset } from './tools/esbuild/bundler-execution-result'; + +export { + executeDevServerBuilder, + type DevServerBuilderOptions, + type DevServerBuilderOutput, +} from './builders/dev-server'; + +export { + execute as executeExtractI18nBuilder, + type ExtractI18nBuilderOptions, +} from './builders/extract-i18n'; + +export { + execute as executeNgPackagrBuilder, + type NgPackagrBuilderOptions, +} from './builders/ng-packagr'; +export { + execute as executeUnitTestBuilder, + type UnitTestBuilderOptions, +} from './builders/unit-test'; + +export { execute as executeKarmaBuilder, type KarmaBuilderOptions } from './builders/karma'; diff --git a/packages/angular/build/src/private.ts b/packages/angular/build/src/private.ts new file mode 100644 index 000000000000..e95ad26af3fc --- /dev/null +++ b/packages/angular/build/src/private.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview + * Private exports intended only for use with the @angular-devkit/build-angular package. + * All exports are not supported for external use, do not provide SemVer guarantees, and + * their existence may change in any future version. + */ + +import { createAngularCompilation } from './tools/angular/compilation'; +import { SecondaryCompilationContext } from './tools/esbuild/angular/compilation-state'; +import { + CompilerPluginOptions, + createCompilerPlugin as internalCreateCompilerPlugin, +} from './tools/esbuild/angular/compiler-plugin'; +import { ComponentStylesheetBundler } from './tools/esbuild/angular/component-stylesheets'; +import { BundleStylesheetOptions } from './tools/esbuild/stylesheets/bundle-options'; + +// Builders +export { buildApplicationInternal } from './builders/application'; +export type { ApplicationBuilderInternalOptions } from './builders/application/options'; +export { type Result, type ResultFile, ResultKind } from './builders/application/results'; +export { serveWithVite } from './builders/dev-server/vite'; +export { + normalizeOptions as normalizeDevServerOptions, + type NormalizedDevServerOptions, +} from './builders/dev-server/options'; + +// Tools +export type { ExternalResultMetadata } from './tools/esbuild/bundler-execution-result'; +export { transformSupportedBrowsersToTargets } from './tools/esbuild/target'; +export { SassCompiler } from './tools/sass/sass-service'; + +export { SourceFileCache } from './tools/esbuild/angular/source-file-cache'; +export { Cache } from './utils/cache'; +export { createJitResourceTransformer } from './tools/angular/transformers/jit-resource-transformer'; +export { JavaScriptTransformer } from './tools/javascript-transformer'; + +export function createCompilerPlugin( + pluginOptions: CompilerPluginOptions & { + browserOnlyBuild?: boolean; + noopTypeScriptCompilation?: boolean; + }, + styleOptions: BundleStylesheetOptions & { inlineStyleLanguage: string }, +): import('esbuild').Plugin { + return internalCreateCompilerPlugin( + pluginOptions, + pluginOptions.noopTypeScriptCompilation + ? new SecondaryCompilationContext() + : () => createAngularCompilation(!!pluginOptions.jit, !!pluginOptions.browserOnlyBuild), + new ComponentStylesheetBundler( + styleOptions, + styleOptions.inlineStyleLanguage, + pluginOptions.incremental, + ), + ); +} + +export type { CompilerPluginOptions, BundleStylesheetOptions }; +export type { AngularCompilation } from './tools/angular/compilation'; +export { DiagnosticModes } from './tools/angular/compilation'; +export { createAngularCompilation }; +export { ComponentStylesheetBundler } from './tools/esbuild/angular/component-stylesheets'; + +// Utilities +export * from './utils/bundle-calculator'; +export { checkPort } from './utils/check-port'; +export { deleteOutputDir } from './utils/delete-output-dir'; +export { emitFilesToDisk } from './utils/disk-files'; +export { type I18nOptions, createI18nOptions, loadTranslations } from './utils/i18n-options'; +export { + IndexHtmlGenerator, + type IndexHtmlGeneratorOptions, + type IndexHtmlGeneratorProcessOptions, + type IndexHtmlTransform, +} from './utils/index-file/index-html-generator'; +export type { FileInfo } from './utils/index-file/augment-index-html'; +export { loadProxyConfiguration } from './utils/load-proxy-config'; +export { type TranslationLoader, createTranslationLoader } from './utils/load-translations'; +export { purgeStaleBuildCache } from './utils/purge-cache'; +export { augmentAppWithServiceWorker } from './utils/service-worker'; +export { type BundleStats, generateBuildStatsTable } from './utils/stats-table'; +export { getSupportedBrowsers } from './utils/supported-browsers'; +export { assertCompatibleAngularVersion } from './utils/version'; +export { findTests, getTestEntrypoints } from './builders/karma/find-tests'; +export { + findTailwindConfiguration, + generateSearchDirectories, + loadPostcssConfiguration, +} from './utils/postcss-configuration'; diff --git a/packages/angular/build/src/tools/angular/angular-host.ts b/packages/angular/build/src/tools/angular/angular-host.ts new file mode 100644 index 000000000000..214666908ddf --- /dev/null +++ b/packages/angular/build/src/tools/angular/angular-host.ts @@ -0,0 +1,286 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type * as ng from '@angular/compiler-cli'; +import assert from 'node:assert'; +import nodePath from 'node:path'; +import type ts from 'typescript'; +import { calculateHash } from '../../utils/hash'; + +export type AngularCompilerOptions = ng.CompilerOptions; +export type AngularCompilerHost = ng.CompilerHost; + +export interface AngularHostOptions { + fileReplacements?: Record; + modifiedFiles?: Set; + externalStylesheets?: Map; + transformStylesheet( + data: string, + containingFile: string, + stylesheetFile?: string, + order?: number, + className?: string, + ): Promise; + processWebWorker(workerFile: string, containingFile: string): string; +} + +/** + * Patches in-place the `getSourceFiles` function on an instance of a TypeScript + * `Program` to ensure that all returned SourceFile instances have a `version` + * field. The `version` field is required when used with a TypeScript BuilderProgram. + * @param program The TypeScript Program instance to patch. + */ +export function ensureSourceFileVersions( + program: ts.Program, + previousProgram?: ts.BuilderProgram, +): void { + const baseGetSourceFiles = program.getSourceFiles; + // TODO: Update Angular compiler to add versions to all internal files and remove this + program.getSourceFiles = function (...parameters) { + const files: readonly (ts.SourceFile & { version?: string })[] = baseGetSourceFiles( + ...parameters, + ); + + for (const file of files) { + if (file.version === undefined) { + const previousFile: (ts.SourceFile & { version?: string }) | undefined = + previousProgram && !file.fileName.endsWith('.ngtypecheck.ts') + ? previousProgram.getSourceFile(file.fileName) + : undefined; + file.version = previousFile?.version ?? calculateHash(file.text); + } + } + + return files; + }; +} + +function augmentHostWithCaching(host: ts.CompilerHost, cache: Map): void { + const baseGetSourceFile = host.getSourceFile; + host.getSourceFile = function ( + fileName, + languageVersion, + onError, + shouldCreateNewSourceFile, + ...parameters + ) { + if (!shouldCreateNewSourceFile && cache.has(fileName)) { + return cache.get(fileName); + } + + const file = baseGetSourceFile.call( + host, + fileName, + languageVersion, + onError, + true, + ...parameters, + ); + + if (file) { + cache.set(fileName, file); + } + + return file; + }; +} + +function augmentResolveModuleNames( + typescript: typeof ts, + host: ts.CompilerHost, + resolvedModuleModifier: ( + resolvedModule: ts.ResolvedModule | undefined, + moduleName: string, + ) => ts.ResolvedModule | undefined, + moduleResolutionCache?: ts.ModuleResolutionCache, +): void { + if (host.resolveModuleNames) { + const baseResolveModuleNames = host.resolveModuleNames; + host.resolveModuleNames = function (moduleNames: string[], ...parameters) { + return moduleNames.map((name) => { + const result = baseResolveModuleNames.call(host, [name], ...parameters); + + return resolvedModuleModifier(result[0], name); + }); + }; + } else { + host.resolveModuleNames = function ( + moduleNames: string[], + containingFile: string, + _reusedNames: string[] | undefined, + redirectedReference: ts.ResolvedProjectReference | undefined, + options: ts.CompilerOptions, + ) { + return moduleNames.map((name) => { + const result = typescript.resolveModuleName( + name, + containingFile, + options, + host, + moduleResolutionCache, + redirectedReference, + ).resolvedModule; + + return resolvedModuleModifier(result, name); + }); + }; + } +} + +function normalizePath(path: string): string { + return nodePath.win32.normalize(path).replace(/\\/g, nodePath.posix.sep); +} + +function augmentHostWithReplacements( + typescript: typeof ts, + host: ts.CompilerHost, + replacements: Record, + moduleResolutionCache?: ts.ModuleResolutionCache, +): void { + if (Object.keys(replacements).length === 0) { + return; + } + + const normalizedReplacements: Record = {}; + for (const [key, value] of Object.entries(replacements)) { + normalizedReplacements[normalizePath(key)] = normalizePath(value); + } + + const tryReplace = (resolvedModule: ts.ResolvedModule | undefined) => { + const replacement = resolvedModule && normalizedReplacements[resolvedModule.resolvedFileName]; + if (replacement) { + return { + resolvedFileName: replacement, + isExternalLibraryImport: /[/\\]node_modules[/\\]/.test(replacement), + }; + } else { + return resolvedModule; + } + }; + + augmentResolveModuleNames(typescript, host, tryReplace, moduleResolutionCache); +} + +export function createAngularCompilerHost( + typescript: typeof ts, + compilerOptions: AngularCompilerOptions, + hostOptions: AngularHostOptions, + packageJsonCache: ts.PackageJsonInfoCache | undefined, + sourceFileCache?: Map, +): AngularCompilerHost { + // Create TypeScript compiler host + const host: AngularCompilerHost = typescript.createIncrementalCompilerHost(compilerOptions); + // Set the parsing mode to the same as TS 5.3+ default for tsc. This provides a parse + // performance improvement by skipping non-type related JSDoc parsing. + host.jsDocParsingMode = typescript.JSDocParsingMode.ParseForTypeErrors; + + // The AOT compiler currently requires this hook to allow for a transformResource hook. + // Once the AOT compiler allows only a transformResource hook, this can be reevaluated. + host.readResource = async function (filename) { + // Only template resources need to be read by the compiler host. + // External stylesheets are read and bundled by the stylesheet bundler during `transformResource`. + if (!hasTemplateExtension(filename)) { + return ''; + } + + return this.readFile(filename) ?? ''; + }; + + // Add an AOT compiler resource transform hook + host.transformResource = async function (data, context) { + // Only style resources are transformed currently + if (context.type !== 'style') { + return null; + } + + assert( + !context.resourceFile || !hostOptions.externalStylesheets?.has(context.resourceFile), + 'External runtime stylesheets should not be transformed: ' + context.resourceFile, + ); + + // No transformation required if an inline resource is empty + if (!context.resourceFile && data.trim().length === 0) { + return { content: '' }; + } + + const result = await hostOptions.transformStylesheet( + data, + context.containingFile, + context.resourceFile ?? undefined, + context.order, + context.className, + ); + + return typeof result === 'string' ? { content: result } : null; + }; + + host.resourceNameToFileName = function (resourceName, containingFile) { + const resolvedPath = nodePath.join(nodePath.dirname(containingFile), resourceName); + if (!this.fileExists(resolvedPath)) { + return null; + } + + // Reject TypeScript files used as component resources (e.g., styleUrl pointing to a .ts file). + // Processing a TypeScript file as a stylesheet or template causes confusing downstream errors. + if (hasTypeScriptExtension(resolvedPath)) { + return null; + } + + // All resource names that have template file extensions are assumed to be templates + // TODO: Update compiler to provide the resource type to avoid extension matching here. + if (!hostOptions.externalStylesheets || hasTemplateExtension(resolvedPath)) { + return resolvedPath; + } + + // For external stylesheets, create a unique identifier and store the mapping + let externalId = hostOptions.externalStylesheets.get(resolvedPath); + if (externalId === undefined) { + externalId = calculateHash(resolvedPath); + hostOptions.externalStylesheets.set(resolvedPath, externalId); + } + + return externalId + '.css'; + }; + + // Allow the AOT compiler to request the set of changed templates and styles + host.getModifiedResourceFiles = function () { + return hostOptions.modifiedFiles; + }; + + // Provide a resolution cache to ensure package.json lookups are cached + const resolutionCache = typescript.createModuleResolutionCache( + host.getCurrentDirectory(), + host.getCanonicalFileName.bind(host), + compilerOptions, + packageJsonCache, + ); + host.getModuleResolutionCache = () => resolutionCache; + + // Augment TypeScript Host for file replacements option + if (hostOptions.fileReplacements) { + augmentHostWithReplacements(typescript, host, hostOptions.fileReplacements, resolutionCache); + } + + // Augment TypeScript Host with source file caching if provided + if (sourceFileCache) { + augmentHostWithCaching(host, sourceFileCache); + } + + return host; +} + +const TEMPLATE_EXTENSIONS: ReadonlySet = new Set(['.htm', '.html', '.svg']); +const TYPESCRIPT_EXTENSIONS: ReadonlySet = new Set(['.ts', '.tsx', '.mts', '.cts']); + +function hasTemplateExtension(file: string): boolean { + return TEMPLATE_EXTENSIONS.has(nodePath.extname(file).toLowerCase()); +} + +function hasTypeScriptExtension(file: string): boolean { + return TYPESCRIPT_EXTENSIONS.has(nodePath.extname(file).toLowerCase()); +} diff --git a/packages/angular/build/src/tools/angular/angular-host_spec.ts b/packages/angular/build/src/tools/angular/angular-host_spec.ts new file mode 100644 index 000000000000..ab2b6151aed0 --- /dev/null +++ b/packages/angular/build/src/tools/angular/angular-host_spec.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import ts from 'typescript'; +import { type AngularHostOptions, createAngularCompilerHost } from './angular-host'; + +describe('createAngularCompilerHost', () => { + function createHost() { + const transformStylesheet = jasmine + .createSpy('transformStylesheet') + .and.resolveTo('.compiled {}'); + const processWebWorker = jasmine + .createSpy('processWebWorker') + .and.returnValue('worker.js'); + + const host = createAngularCompilerHost( + ts, + {}, + { + transformStylesheet, + processWebWorker, + }, + undefined, + ); + + return { host, transformStylesheet }; + } + + describe('readResource', () => { + it('should not call readFile for .css and .scss stylesheet files', async () => { + const { host } = createHost(); + const readFileSpy = spyOn(host, 'readFile').and.returnValue('.btn { color: red; }'); + + expect(await host.readResource?.('/app/src/app.component.css')).toBe(''); + expect(await host.readResource?.('/app/src/app.component.scss')).toBe(''); + expect(readFileSpy).not.toHaveBeenCalled(); + }); + + it('should call readFile for .html and .svg template files', async () => { + const { host } = createHost(); + const readFileSpy = spyOn(host, 'readFile').and.callFake( + (fileName) => ``, + ); + + expect(await host.readResource?.('/app/src/app.component.html')).toBe( + '', + ); + expect(await host.readResource?.('/app/src/icon.component.svg')).toBe( + '', + ); + expect(readFileSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('transformResource', () => { + it('should invoke transformStylesheet when context.resourceFile is set even if data is empty', async () => { + const { host, transformStylesheet } = createHost(); + + const result = await host.transformResource?.('', { + type: 'style', + containingFile: '/app/src/app.component.ts', + resourceFile: '/app/src/app.component.scss', + order: 0, + className: 'AppComponent', + }); + + expect(result).toEqual({ content: '.compiled {}' }); + expect(transformStylesheet).toHaveBeenCalledOnceWith( + '', + '/app/src/app.component.ts', + '/app/src/app.component.scss', + 0, + 'AppComponent', + ); + }); + + it('should short-circuit empty inline styles without calling transformStylesheet', async () => { + const { host, transformStylesheet } = createHost(); + + const result = await host.transformResource?.(' ', { + type: 'style', + containingFile: '/app/src/app.component.ts', + resourceFile: null, + order: 0, + className: 'AppComponent', + }); + + expect(result).toEqual({ content: '' }); + expect(transformStylesheet).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts b/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts new file mode 100644 index 000000000000..3da78cdf0275 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { PartialMessage } from 'esbuild'; +import type { AngularHostOptions } from '../angular-host'; +import type { CompilerOptionOverrides } from './compiler-options'; + +export interface EmitFileResult { + filename: string; + contents: string; + dependencies?: readonly string[]; +} + +export interface FileTransformResult { + contents: string; + watchFiles?: readonly string[]; +} + +export interface AngularCompilationOptions { + allowJs?: boolean; + compilationMode?: 'full' | 'partial' | 'experimental-local'; + declarationMap?: boolean; + isolatedModules?: boolean; + sourceMap?: boolean; + inlineSourceMap?: boolean; + _useTypeScriptTranspilation?: boolean; + [key: string]: unknown; +} + +export interface AngularCompilationResult { + compilerOptions: AngularCompilationOptions; + referencedFiles: readonly string[]; + externalStylesheets?: ReadonlyMap; + templateUpdates?: ReadonlyMap; + componentResourcesDependencies?: ReadonlyMap; + warnings?: readonly PartialMessage[]; +} + +export enum DiagnosticModes { + None = 0, + Option = 1 << 0, + Syntactic = 1 << 1, + Semantic = 1 << 2, + All = Option | Syntactic | Semantic, +} + +export abstract class AngularCompilation { + abstract initialize( + tsconfig: string, + hostOptions: AngularHostOptions, + compilerOptionOverrides?: CompilerOptionOverrides, + buildType?: 'application' | 'library', + ): Promise; + + emitAffectedFiles(): Iterable | Promise> { + return []; + } + + transformFile?(filename: string, content: string): Promise; + + async diagnoseFiles( + modes?: DiagnosticModes, + ): Promise<{ errors?: PartialMessage[]; warnings?: PartialMessage[] }> { + return {}; + } + + update?(files: Set): Promise; + + close?(): Promise; +} diff --git a/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts b/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts new file mode 100644 index 000000000000..e0aad6a77db4 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts @@ -0,0 +1,650 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import path from 'node:path'; +import ts from 'typescript'; +import type { AngularHostOptions } from '../angular-host'; +import { ensurePreDeclarationTransformers } from './aot-compilation'; +import { CompilerOptionOverrides, transformCompilerOptions } from './compiler-options'; +import { TypeScriptCompilation } from './typescript-compilation'; +import { + AngularCompilation, + AngularCompilationResult, + DiagnosticModes, + createAngularCompilation, +} from './index'; + +describe('AngularCompilation', () => { + class CustomTransformCompilation extends AngularCompilation { + override async initialize(): Promise { + return { + compilerOptions: { allowJs: false }, + referencedFiles: ['/src/main.ts'], + }; + } + + override async transformFile(filename: string, content: string) { + if (filename.endsWith('.ts')) { + return { + contents: content + '\n// transformed', + watchFiles: ['/src/dep.ts'], + }; + } + + return null; + } + } + + it('allows implementing on-demand transformFile without collectDiagnostics', async () => { + const compilation = new CustomTransformCompilation(); + const initResult = await compilation.initialize(); + + expect(initResult.referencedFiles).toEqual(['/src/main.ts']); + expect(initResult.compilerOptions.allowJs).toBe(false); + + const transformResult = await compilation.transformFile?.( + '/src/main.ts', + 'console.log("hello");', + ); + expect(transformResult).toEqual({ + contents: 'console.log("hello");\n// transformed', + watchFiles: ['/src/dep.ts'], + }); + + const diagnostics = await compilation.diagnoseFiles(); + expect(diagnostics).toEqual({}); + }); + + describe('TypeScriptCompilation', () => { + class MockTypeScriptCompilation extends TypeScriptCompilation { + async initialize(): Promise { + return { compilerOptions: {}, referencedFiles: [] }; + } + + protected override *collectDiagnostics(modes: DiagnosticModes): Iterable { + if (modes & DiagnosticModes.Option) { + yield { + category: ts.DiagnosticCategory.Error, + code: 1234, + messageText: 'Mock option error', + file: undefined, + start: undefined, + length: undefined, + }; + } + if (modes & DiagnosticModes.Semantic) { + yield { + category: ts.DiagnosticCategory.Warning, + code: 5678, + messageText: 'Mock semantic warning', + file: undefined, + start: undefined, + length: undefined, + }; + } + } + + public getCachedSourceFiles(): Map { + return this.sourceFiles; + } + + public async testLoadConfiguration( + tsconfig: string, + overrides?: CompilerOptionOverrides, + buildType?: 'application' | 'library', + ) { + return this.loadConfiguration(tsconfig, overrides, buildType); + } + } + + it('collects and converts diagnostics categorized by error and warning', async () => { + const compilation = new MockTypeScriptCompilation(); + const diagnostics = await compilation.diagnoseFiles(DiagnosticModes.All); + + expect(diagnostics.errors?.length).toBe(1); + expect(diagnostics.errors?.[0].text).toContain('Mock option error'); + expect(diagnostics.warnings?.length).toBe(1); + expect(diagnostics.warnings?.[0].text).toContain('Mock semantic warning'); + }); + + it('filters diagnostics according to requested DiagnosticModes', async () => { + const compilation = new MockTypeScriptCompilation(); + const diagnostics = await compilation.diagnoseFiles(DiagnosticModes.Option); + + expect(diagnostics.errors?.length).toBe(1); + expect(diagnostics.errors?.[0].text).toContain('Mock option error'); + expect(diagnostics.warnings).toBeUndefined(); + }); + + it('returns empty diagnostics immediately when mode is DiagnosticModes.None', async () => { + const compilation = new MockTypeScriptCompilation(); + const diagnostics = await compilation.diagnoseFiles(DiagnosticModes.None); + + expect(diagnostics).toEqual({}); + }); + + it('evicts files from AST cache on update and invalidateFiles', async () => { + const compilation = new MockTypeScriptCompilation(); + const mockSourceFile = ts.createSourceFile('test.ts', '', ts.ScriptTarget.Latest); + compilation.getCachedSourceFiles().set('/src/test.ts', mockSourceFile); + + expect(compilation.getCachedSourceFiles().has('/src/test.ts')).toBeTrue(); + + await compilation.update?.(new Set(['/src/test.ts'])); + expect(compilation.getCachedSourceFiles().has('/src/test.ts')).toBeFalse(); + }); + + it('dynamically loads the @angular/compiler-cli module', async () => { + const compilerCli = await TypeScriptCompilation.loadCompilerCli(); + expect(compilerCli).toBeDefined(); + expect(typeof compilerCli.readConfiguration).toBe('function'); + }); + + it('loads configuration and applies compiler option transformations', async () => { + const compilation = new MockTypeScriptCompilation(); + const mockReadConfig = jasmine.createSpy('readConfiguration').and.returnValue({ + options: { target: ts.ScriptTarget.ES2020 }, + rootNames: ['/src/main.ts'], + errors: [], + }); + spyOn(TypeScriptCompilation, 'loadCompilerCli').and.resolveTo({ + readConfiguration: mockReadConfig, + } as unknown as typeof import('@angular/compiler-cli')); + + const result = await compilation.testLoadConfiguration('tsconfig.json', { sourcemap: true }); + + expect(mockReadConfig).toHaveBeenCalledWith( + 'tsconfig.json', + jasmine.objectContaining({ + suppressOutputPathCheck: true, + outDir: undefined, + }), + undefined, + jasmine.any(Map), + ); + expect(result.rootNames).toEqual(['/src/main.ts']); + expect(result.fallbackRootNames).toBeUndefined(); + expect(result.compilerOptions.target).toBe(ts.ScriptTarget.ES2022); + expect(result.compilerOptions.inlineSources).toBe(true); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.tsConfigFiles).toEqual(['tsconfig.json']); + }); + + it('filters rootNames to rootFiles while retaining ambient declaration files and setting fallbackRootNames', async () => { + const compilation = new MockTypeScriptCompilation(); + const mockReadConfig = jasmine.createSpy('readConfiguration').and.returnValue({ + options: { target: ts.ScriptTarget.ES2022 }, + rootNames: [ + '/src/app/included.spec.ts', + '/src/app/excluded.spec.ts', + '/src/app/app-module.ts', + '/src/env.d.ts', + '/src/types.d.mts', + '/src/legacy.d.cts', + ], + errors: [], + }); + spyOn(TypeScriptCompilation, 'loadCompilerCli').and.resolveTo({ + readConfiguration: mockReadConfig, + } as unknown as typeof import('@angular/compiler-cli')); + + const result = await compilation.testLoadConfiguration('tsconfig.spec.json', { + rootFiles: ['/src/app/included.spec.ts'], + }); + + expect(result.rootNames).toEqual([ + '/src/app/included.spec.ts', + '/src/env.d.ts', + '/src/types.d.mts', + '/src/legacy.d.cts', + ]); + expect(result.fallbackRootNames).toEqual([ + '/src/app/included.spec.ts', + '/src/app/excluded.spec.ts', + '/src/app/app-module.ts', + '/src/env.d.ts', + '/src/types.d.mts', + '/src/legacy.d.cts', + ]); + }); + + it('omits fallbackRootNames when buildType is "library" or when rootFiles covers all tsconfig files', async () => { + const compilation = new MockTypeScriptCompilation(); + const mockReadConfig = jasmine.createSpy('readConfiguration').and.returnValue({ + options: { target: ts.ScriptTarget.ES2022 }, + rootNames: ['/src/public-api.ts', '/src/internal.ts', '/src/env.d.ts'], + errors: [], + }); + spyOn(TypeScriptCompilation, 'loadCompilerCli').and.resolveTo({ + readConfiguration: mockReadConfig, + } as unknown as typeof import('@angular/compiler-cli')); + + const libraryResult = await compilation.testLoadConfiguration( + 'tsconfig.lib.json', + { rootFiles: ['/src/public-api.ts'] }, + 'library', + ); + expect(libraryResult.rootNames).toEqual(['/src/public-api.ts', '/src/env.d.ts']); + expect(libraryResult.fallbackRootNames).toBeUndefined(); + + const fullSetResult = await compilation.testLoadConfiguration( + 'tsconfig.app.json', + { rootFiles: ['/src/public-api.ts', '/src/internal.ts'] }, + 'application', + ); + expect(fullSetResult.rootNames).toEqual([ + '/src/public-api.ts', + '/src/internal.ts', + '/src/env.d.ts', + ]); + expect(fullSetResult.fallbackRootNames).toBeUndefined(); + }); + + it('passes extendedConfigCache to readConfiguration and invalidates entries on config change', async () => { + const compilation = new MockTypeScriptCompilation(); + let passedCache: Map | undefined; + const mockReadConfig = jasmine + .createSpy('readConfiguration') + .and.callFake((_project, _options, _host, extendedConfigCache) => { + passedCache = extendedConfigCache; + extendedConfigCache?.set( + '/path/to/tsconfig.base.json', + {} as ts.ExtendedConfigCacheEntry, + ); + + return { + options: { target: ts.ScriptTarget.ES2020 }, + rootNames: ['/src/main.ts'], + errors: [], + }; + }); + spyOn(TypeScriptCompilation, 'loadCompilerCli').and.resolveTo({ + readConfiguration: mockReadConfig, + } as unknown as typeof import('@angular/compiler-cli')); + + const result1 = await compilation.testLoadConfiguration('tsconfig.json'); + expect(mockReadConfig).toHaveBeenCalledTimes(1); + expect(passedCache).toBeDefined(); + expect(passedCache?.has('/path/to/tsconfig.base.json')).toBeTrue(); + expect(result1.tsConfigFiles).toEqual(['tsconfig.json', '/path/to/tsconfig.base.json']); + + // Invalidation of non-config file should keep extended config cache intact + await compilation.update?.(new Set(['/src/main.ts'])); + expect(passedCache?.has('/path/to/tsconfig.base.json')).toBeTrue(); + + // Invalidation of extended tsconfig file should remove it from cache + await compilation.update?.(new Set(['/path/to/tsconfig.base.json'])); + expect(passedCache?.has('/path/to/tsconfig.base.json')).toBeFalse(); + + // Invalidation with different casing should also remove it from cache + passedCache?.set('/path/to/tsconfig.base.json', {} as ts.ExtendedConfigCacheEntry); + await compilation.update?.(new Set(['/PATH/TO/TSCONFIG.BASE.JSON'])); + expect(passedCache?.has('/path/to/tsconfig.base.json')).toBeFalse(); + + // Subsequent configuration load re-uses the same cache instance + await compilation.testLoadConfiguration('tsconfig.json'); + expect(mockReadConfig).toHaveBeenCalledTimes(2); + expect(mockReadConfig.calls.argsFor(1)[3]).toBe(passedCache); + }); + }); + + describe('createAngularCompilation', () => { + it('creates JitCompilation when mode is "jit"', async () => { + const compilation = await createAngularCompilation('jit', true, false); + expect(compilation.constructor.name).toBe('JitCompilation'); + }); + + it('creates AotCompilation when mode is "aot"', async () => { + const compilation = await createAngularCompilation('aot', true, false); + expect(compilation.constructor.name).toBe('AotCompilation'); + }); + + it('creates JitCompilation when mode is boolean true (legacy)', async () => { + const compilation = await createAngularCompilation(true, true, false); + expect(compilation.constructor.name).toBe('JitCompilation'); + }); + + it('creates AotCompilation when mode is boolean false (legacy)', async () => { + const compilation = await createAngularCompilation(false, true, false); + expect(compilation.constructor.name).toBe('AotCompilation'); + }); + + it('throws when mode is "transform"', async () => { + await expectAsync(createAngularCompilation('transform', true, false)).toBeRejectedWithError( + 'Transform compilation mode is not supported.', + ); + }); + }); + + describe('transformCompilerOptions', () => { + it('does not mutate the input compiler options object', () => { + const originalOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + }; + const originalCopy = { ...originalOptions }; + + transformCompilerOptions(ts, originalOptions); + + expect(originalOptions).toEqual(originalCopy); + }); + + it('sets target to ES2022 and useDefineForClassFields to false when target is undefined', () => { + const { compilerOptions, warnings } = transformCompilerOptions( + ts, + { module: ts.ModuleKind.ES2022 }, + undefined, + 'tsconfig.json', + ); + + expect(compilerOptions.target).toBe(ts.ScriptTarget.ES2022); + expect(compilerOptions.useDefineForClassFields).toBe(false); + expect(warnings.length).toBe(1); + expect(warnings[0].text).toContain( + "TypeScript compiler options 'target' and 'useDefineForClassFields'", + ); + expect(warnings[0].location?.file).toBe('tsconfig.json'); + }); + + it('preserves existing useDefineForClassFields if target < ES2022', () => { + const { compilerOptions, warnings } = transformCompilerOptions( + ts, + { + target: ts.ScriptTarget.ES2020, + useDefineForClassFields: true, + module: ts.ModuleKind.ES2022, + }, + undefined, + 'tsconfig.json', + ); + + expect(compilerOptions.target).toBe(ts.ScriptTarget.ES2022); + expect(compilerOptions.useDefineForClassFields).toBe(true); + expect(warnings.length).toBe(1); + }); + + it('sets compilationMode to full and warns when compilationMode is partial', () => { + const { compilerOptions, warnings } = transformCompilerOptions(ts, { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + compilationMode: 'partial', + }); + + expect(compilerOptions.compilationMode).toBe('full'); + expect(warnings.length).toBe(1); + expect(warnings[0].text).toContain('Angular partial compilation mode is not supported'); + }); + + it('configures incremental and tsBuildInfoFile when cachePath is provided', () => { + const { compilerOptions } = transformCompilerOptions( + ts, + { target: ts.ScriptTarget.ES2022 }, + { cachePath: '/tmp/cache' }, + ); + + expect(compilerOptions.incremental).toBe(true); + expect(compilerOptions.tsBuildInfoFile).toContain('.tsbuildinfo'); + }); + + it('sets incremental to false when cachePath is not provided or incremental is false', () => { + const { compilerOptions: opt1 } = transformCompilerOptions( + ts, + { target: ts.ScriptTarget.ES2022 }, + undefined, + ); + expect(opt1.incremental).toBe(false); + + const { compilerOptions: opt2 } = transformCompilerOptions( + ts, + { target: ts.ScriptTarget.ES2022, incremental: false }, + { cachePath: '/tmp/cache' }, + ); + expect(opt2.incremental).toBe(false); + }); + + it('sets module to ES2022 and warns when module < ES2015', () => { + const { compilerOptions, warnings } = transformCompilerOptions(ts, { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + }); + + expect(compilerOptions.module).toBe(ts.ModuleKind.ES2022); + expect(warnings.length).toBe(1); + expect(warnings[0].text).toContain( + "TypeScript compiler options 'module' values 'CommonJS', 'UMD'", + ); + }); + + it('warns when isolatedModules is enabled with emitDecoratorMetadata', () => { + const { warnings } = transformCompilerOptions(ts, { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + isolatedModules: true, + emitDecoratorMetadata: true, + }); + + expect(warnings.length).toBe(1); + expect(warnings[0].text).toContain( + "TypeScript compiler option 'isolatedModules' may prevent", + ); + }); + + it('synchronizes customConditions when moduleResolution is Bundler or module is Preserve', () => { + const { compilerOptions: bundlerOptions } = transformCompilerOptions( + ts, + { target: ts.ScriptTarget.ES2022, moduleResolution: ts.ModuleResolutionKind.Bundler }, + { customConditions: ['development'] }, + ); + expect(bundlerOptions.customConditions).toEqual(['development']); + + const { compilerOptions: preserveOptions } = transformCompilerOptions( + ts, + { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.Preserve }, + { customConditions: ['development'] }, + ); + expect(preserveOptions.customConditions).toEqual(['development']); + }); + + it('applies override options correctly', () => { + const { compilerOptions } = transformCompilerOptions( + ts, + { + target: ts.ScriptTarget.ES2022, + isolatedModules: true, + basePath: '/workspace/projects/app', + }, + { + sourcemap: true, + preserveSymlinks: true, + externalRuntimeStyles: true, + enableHmr: true, + instrumentForCoverage: true, + includeTestMetadata: true, + basePath: '/workspace', + }, + ); + + expect(compilerOptions.basePath).toBe('/workspace'); + expect(compilerOptions.inlineSources).toBe(true); + expect(compilerOptions.inlineSourceMap).toBe(true); + expect(compilerOptions.preserveSymlinks).toBe(true); + expect(compilerOptions.externalRuntimeStyles).toBe(true); + expect(compilerOptions['_enableHmr']).toBe(true); + expect(compilerOptions['_useTypeScriptTranspilation']).toBe(true); + expect(compilerOptions.supportTestBed).toBe(true); + expect(compilerOptions.supportJitMode).toBe(true); + expect(compilerOptions.noEmit).toBe(false); + expect(compilerOptions.noEmitOnError).toBe(false); + expect(compilerOptions.emitDeclarationOnly).toBe(false); + expect(compilerOptions.composite).toBe(false); + }); + + it('enforces library compiler options and skips app-only target warning', () => { + const { compilerOptions, warnings } = transformCompilerOptions( + ts, + { + target: ts.ScriptTarget.ES2020, + noEmit: true, + emitDeclarationOnly: true, + isolatedModules: true, + }, + { + cachePath: '/tmp/cache', + }, + 'tsconfig.lib.json', + 'library', + ); + + expect(compilerOptions.target).toBe(ts.ScriptTarget.ES2022); + expect(compilerOptions.module).toBe(ts.ModuleKind.Preserve); + expect(compilerOptions.useDefineForClassFields).toBeUndefined(); + expect(compilerOptions.noEmit).toBe(false); + expect(compilerOptions.emitDeclarationOnly).toBe(false); + expect(compilerOptions.incremental).toBe(true); + expect(compilerOptions.tsBuildInfoFile).toBe(path.join('/tmp/cache', '.tsbuildinfo')); + expect(warnings).toEqual([]); + }); + }); + + describe('ensurePreDeclarationTransformers', () => { + function createTestProgram( + fileName: string, + content: string, + ): { + program: ts.Program; + sourceFile: ts.SourceFile; + } { + const options: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + declaration: true, + noLib: true, + }; + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ES2022, true); + const host = ts.createCompilerHost(options); + host.getSourceFile = (name) => (name === fileName ? sourceFile : undefined); + host.fileExists = (name) => name === fileName; + host.readFile = (name) => (name === fileName ? content : undefined); + host.writeFile = () => {}; + + const program = ts.createProgram([fileName], options, host); + + return { program, sourceFile }; + } + + it('transforms each SourceFile only once when emitting .d.ts followed by .js and reuses the cached SourceFile', () => { + const { program, sourceFile } = createTestProgram('/index.ts', 'export const value = 1;'); + + let preDeclStatePopulated = false; + const beforeSpy1 = jasmine + .createSpy('beforeTransformer1') + .and.callFake((sf: ts.SourceFile) => { + preDeclStatePopulated = true; + const addedStmt = ts.factory.createVariableStatement( + [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + 'fromBefore1', + undefined, + undefined, + ts.factory.createNumericLiteral(2), + ), + ], + ts.NodeFlags.Const, + ), + ); + + return ts.factory.updateSourceFile(sf, [...sf.statements, addedStmt]); + }); + + const beforeSpy2 = jasmine + .createSpy('beforeTransformer2') + .and.callFake((sf: ts.SourceFile) => { + const addedStmt = ts.factory.createVariableStatement( + [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + 'fromBefore2', + undefined, + undefined, + ts.factory.createNumericLiteral(3), + ), + ], + ts.NodeFlags.Const, + ), + ); + + return ts.factory.updateSourceFile(sf, [...sf.statements, addedStmt]); + }); + + let sawPopulatedPreDeclState = false; + const afterDeclSpy = jasmine + .createSpy('afterDeclarationsTransformer') + .and.callFake((sf: ts.SourceFile) => { + sawPopulatedPreDeclState = preDeclStatePopulated; + + return sf; + }); + + const transformers: ts.CustomTransformers = { + before: [() => beforeSpy1, () => beforeSpy2], + afterDeclarations: [() => afterDeclSpy], + }; + + ensurePreDeclarationTransformers(transformers); + + const emittedFiles = new Map(); + const writeFile: ts.WriteFileCallback = (fileName, text) => { + emittedFiles.set(fileName, text); + }; + + // Emit .d.ts first (emitOnlyDtsFiles = true) + program.emit(sourceFile, writeFile, undefined, true, transformers); + + expect(sawPopulatedPreDeclState).toBeTrue(); + expect(beforeSpy1).toHaveBeenCalledTimes(1); + expect(beforeSpy2).toHaveBeenCalledTimes(1); + expect(afterDeclSpy).toHaveBeenCalledTimes(1); + expect(emittedFiles.has('/index.d.ts')).toBeTrue(); + expect(emittedFiles.has('/index.js')).toBeFalse(); + + // Emit .js second; before transformers must not execute again and cached AST must be emitted + program.emit(sourceFile, writeFile, undefined, 0 as unknown as boolean, transformers); + + expect(beforeSpy1).toHaveBeenCalledTimes(1); + expect(beforeSpy2).toHaveBeenCalledTimes(1); + expect(emittedFiles.get('/index.js')).toContain('export const fromBefore1 = 2;'); + expect(emittedFiles.get('/index.js')).toContain('export const fromBefore2 = 3;'); + }); + + it('transforms each SourceFile only once when emitting .js and .d.ts together', () => { + const { program, sourceFile } = createTestProgram('/index.ts', 'export const value = 1;'); + + const beforeSpy = jasmine + .createSpy('beforeTransformer') + .and.callFake((sf: ts.SourceFile) => sf); + const afterDeclSpy = jasmine + .createSpy('afterDeclarationsTransformer') + .and.callFake((sf: ts.SourceFile) => sf); + + const transformers: ts.CustomTransformers = { + before: [() => beforeSpy], + afterDeclarations: [() => afterDeclSpy], + }; + + ensurePreDeclarationTransformers(transformers); + + program.emit(sourceFile, () => {}, undefined, false, transformers); + + expect(beforeSpy).toHaveBeenCalledTimes(1); + expect(afterDeclSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts new file mode 100644 index 000000000000..8a3e559c9c69 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts @@ -0,0 +1,1051 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type * as ng from '@angular/compiler-cli'; +import assert from 'node:assert'; +import { relative } from 'node:path'; +import ts from 'typescript'; +import { useTypeChecking } from '../../../utils/environment-options'; +import { profileAsync, profileSync } from '../../../utils/profiling'; +import { + AngularHostOptions, + createAngularCompilerHost, + ensureSourceFileVersions, +} from '../angular-host'; +import { replaceBootstrap } from '../transformers/jit-bootstrap-transformer'; +import { lazyRoutesTransformer } from '../transformers/lazy-routes-transformer'; +import { createWorkerTransformer } from '../transformers/web-worker-transformer'; +import { + type AngularCompilationResult, + DiagnosticModes, + type EmitFileResult, +} from './angular-compilation'; +import type { CompilerOptionOverrides } from './compiler-options'; +import { collectHmrCandidates } from './hmr-candidates'; +import { TypeScriptCompilation } from './typescript-compilation'; +import { printSourceFileWithMap } from './typescript-printer'; + +/** + * The modified files count limit for performing component HMR analysis. + * Performing content analysis for a large amount of files can result in longer rebuild times + * than a full rebuild would entail. + */ +const HMR_MODIFIED_FILE_LIMIT = 32; + +/** + * Extension for Angular type-checking files. + */ +const TTC_EXTENSION = '.ngtypecheck.ts'; + +/** + * Length of the `.ngtypecheck.ts` extension. + */ +const TTC_EXTENSION_LENGTH = TTC_EXTENSION.length; + +/** + * Internal TypeScript `ts.EmitOnly` enum values used by `Program.emit`. + */ +enum EmitOnly { + Js = 0, + Dts = 1, + BuilderSignature = 2, +} + +/** + * Checks if a file is an Angular type-checking shim file. + */ +function isNgTypeCheckFile(fileName: string): boolean { + return fileName.endsWith(TTC_EXTENSION); +} + +/** + * Returns the original filename for a type-checking shim file. + */ +function getOriginalFilename(fileName: string): string { + return fileName.slice(0, -TTC_EXTENSION_LENGTH) + '.ts'; +} + +class AngularCompilationState { + constructor( + public readonly angularProgram: ng.NgtscProgram, + public readonly compilerHost: ng.CompilerHost, + public typeScriptProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram, + public readonly affectedFiles: ReadonlySet, + public readonly templateDiagnosticsOptimization: ng.OptimizeFor, + public readonly webWorkerTransform: ts.TransformerFactory, + public readonly useTypeScriptTranspilation: boolean, + public readonly diagnosticCache = new WeakMap(), + public readonly declarationDiagnostics = new WeakMap(), + public readonly filesWithoutDtsSignature = new Set(), + public readonly useFallbackRootNames = false, + public readonly emittedDtsSourceFiles = new WeakSet(), + ) {} + + get angularCompiler() { + return this.angularProgram.compiler; + } +} + +export class AotCompilation extends TypeScriptCompilation { + #state?: AngularCompilationState; + + constructor(private readonly browserOnlyBuild: boolean) { + super(); + } + + // eslint-disable-next-line max-lines-per-function + async initialize( + tsconfig: string, + hostOptions: AngularHostOptions, + compilerOptionOverrides?: CompilerOptionOverrides, + buildType: 'application' | 'library' = 'application', + ): Promise { + // Dynamically load the Angular compiler CLI package + const { NgtscProgram, OptimizeFor } = await TypeScriptCompilation.loadCompilerCli(); + + // Load the compiler configuration and transform as needed + const { + compilerOptions, + rootNames, + fallbackRootNames, + errors: configurationDiagnostics, + warnings, + tsConfigFiles, + } = await this.loadConfiguration(tsconfig, compilerOptionOverrides, buildType); + + const useTypeScriptTranspilation = + (compilerOptions['_useTypeScriptTranspilation'] as boolean | undefined) ?? + !compilerOptions.isolatedModules; + + if (compilerOptions.externalRuntimeStyles) { + hostOptions.externalStylesheets ??= new Map(); + } + + // Reuse the package.json cache from the previous compilation + const packageJsonCache = this.#state?.compilerHost + .getModuleResolutionCache?.() + ?.getPackageJsonInfoCache(); + + const useHmr = + compilerOptions['_enableHmr'] && + hostOptions.modifiedFiles && + hostOptions.modifiedFiles.size <= HMR_MODIFIED_FILE_LIMIT; + + let staleSourceFiles; + let clearPackageJsonCache = false; + if (hostOptions.modifiedFiles) { + this.invalidateFiles(hostOptions.modifiedFiles); + + if (this.#state && hostOptions.modifiedFiles.size > 0) { + for (const modifiedFile of hostOptions.modifiedFiles) { + // Clear package.json cache if a node modules file was modified + if (!clearPackageJsonCache && modifiedFile.includes('node_modules')) { + clearPackageJsonCache = true; + packageJsonCache?.clear(); + } + + // Collect stale source files for HMR analysis of inline component resources + if (useHmr) { + const sourceFile = this.#state.typeScriptProgram.getSourceFile(modifiedFile); + if (sourceFile) { + staleSourceFiles ??= new Map(); + staleSourceFiles.set(modifiedFile, sourceFile); + } + } + } + + if (useTypeChecking || useTypeScriptTranspilation) { + populatePreviousDtsSignatures( + this.#state.typeScriptProgram, + hostOptions.modifiedFiles, + this.#state.filesWithoutDtsSignature, + ); + } + } + } + + // Create Angular compiler host + const host = createAngularCompilerHost( + ts, + compilerOptions, + hostOptions, + packageJsonCache, + this.sourceFiles, + ); + + // If a previous watch rebuild already fell back to the full tsconfig root file set, + // start directly with fallbackRootNames to avoid repeating the two-step check. + let useFallbackRootNames = this.#state?.useFallbackRootNames ?? false; + + // Create the Angular specific program that contains the Angular compiler + let angularProgram = profileSync( + 'NG_CREATE_PROGRAM', + () => + new NgtscProgram( + (useFallbackRootNames && fallbackRootNames) || rootNames, + compilerOptions, + host, + this.#state?.angularProgram, + ), + ); + let angularCompiler = angularProgram.compiler; + + await profileAsync('NG_ANALYZE_PROGRAM', () => angularCompiler.analyzeAsync()); + + let angularTypeScriptProgram = angularProgram.getTsProgram(); + + // When rootFiles filtering is active and a non-standalone component without a resolved + // NgModule scope is encountered, its declaring NgModule may not be directly imported by + // the test entry points. Fall back to the full tsconfig root file set if it contains + // additional uncompiled files so the Angular compiler can discover all NgModules and + // resolve component compilation scopes. + if ( + !useFallbackRootNames && + fallbackRootNames?.some((file) => !angularTypeScriptProgram.getSourceFile(file)) && + hasUnscopedComponent(angularCompiler) + ) { + useFallbackRootNames = true; + angularProgram = profileSync( + 'NG_CREATE_PROGRAM_FALLBACK', + () => new NgtscProgram(fallbackRootNames, compilerOptions, host, angularProgram), + ); + angularCompiler = angularProgram.compiler; + + await profileAsync('NG_ANALYZE_PROGRAM_FALLBACK', () => angularCompiler.analyzeAsync()); + angularTypeScriptProgram = angularProgram.getTsProgram(); + } + + ensureSourceFileVersions(angularTypeScriptProgram); + + let oldProgram = this.#state?.typeScriptProgram; + let usingBuildInfo = false; + if (!oldProgram) { + oldProgram = ts.readBuilderProgram(compilerOptions, host); + usingBuildInfo = !!oldProgram; + } + + const typeScriptProgram = ts.createEmitAndSemanticDiagnosticsBuilderProgram( + angularTypeScriptProgram, + host, + oldProgram, + configurationDiagnostics, + ); + + const filesWithoutDtsSignature = this.#state?.filesWithoutDtsSignature ?? new Set(); + if (!this.#state && !usingBuildInfo && (useTypeChecking || useTypeScriptTranspilation)) { + for (const sourceFile of typeScriptProgram.getSourceFiles()) { + if ( + !sourceFile.isDeclarationFile && + !angularCompiler.ignoreForDiagnostics.has(sourceFile) + ) { + filesWithoutDtsSignature.add(sourceFile.fileName); + } + } + } + + let templateUpdates; + if (useHmr && hostOptions.modifiedFiles && this.#state) { + const componentNodes = collectHmrCandidates( + hostOptions.modifiedFiles, + angularProgram, + staleSourceFiles, + ); + + for (const node of componentNodes) { + if (!ts.isClassDeclaration(node)) { + continue; + } + const componentFilename = node.getSourceFile().fileName; + let relativePath = relative( + compilerOptions.rootDir ?? host.getCurrentDirectory(), + componentFilename, + ); + if (relativePath.startsWith('..')) { + relativePath = componentFilename; + } + relativePath = relativePath.replaceAll('\\', '/'); + const updateId = encodeURIComponent( + `${host.getCanonicalFileName(relativePath)}@${node.name?.text}`, + ); + const updateText = angularCompiler.emitHmrUpdateModule(node); + // If compiler cannot generate an update for the component, prevent template updates. + if (updateText === null) { + // Build is needed if a template cannot be updated + templateUpdates = undefined; + break; + } + templateUpdates ??= new Map(); + templateUpdates.set(updateId, updateText); + } + } + + // The affected files walk runs a full semantic type-check as a side effect + // (via `getSemanticDiagnosticsOfNextAffectedFile`). Its result is only consumed to + // scope Angular template diagnostics and to force re-emit of TS-affected files in the + // slow TypeScript emit path. When type-checking is disabled, template/semantic + // diagnostics are already suppressed at the consumption layer (see the compiler-plugin + // `diagnoseFiles` mask), and in the isolatedModules fast path emit is per-file, so the + // set is never read. Skip the walk in that case to avoid a discarded semantic pass. + const affectedFiles = + useTypeChecking || useTypeScriptTranspilation + ? profileSync('NG_FIND_AFFECTED', () => + findAffectedFiles( + typeScriptProgram, + angularCompiler, + usingBuildInfo, + this.#state?.typeScriptProgram, + filesWithoutDtsSignature, + ), + ) + : new Set(); + + const componentResourcesDependencies = new Map(); + + // Get all files referenced in the TypeScript/Angular program including component resources + const referencedFiles = [...tsConfigFiles]; + for (const sourceFile of typeScriptProgram.getSourceFiles()) { + if (angularCompiler.ignoreForEmit.has(sourceFile)) { + continue; + } + + referencedFiles.push(sourceFile.fileName); + if (sourceFile.isDeclarationFile) { + continue; + } + + const resourceDependencies = angularCompiler.getResourceDependencies(sourceFile); + componentResourcesDependencies.set(sourceFile.fileName, resourceDependencies); + + if (resourceDependencies.length === 0) { + continue; + } + + referencedFiles.push(...resourceDependencies); + + // Also invalidate Angular diagnostics for a source file if component resources are modified + if (this.#state && hostOptions.modifiedFiles?.size) { + for (const resourceDependency of resourceDependencies) { + if ( + !hostOptions.modifiedFiles.has(resourceDependency) || + /\.(?:css|scss|sass|less)$/i.test(resourceDependency) + ) { + continue; + } + + this.#state.diagnosticCache.delete(sourceFile); + + const oldSourceFile = this.#state.typeScriptProgram.getSourceFile(sourceFile.fileName); + if (oldSourceFile) { + this.#state.diagnosticCache.delete(oldSourceFile); + } + + // Also mark as affected in case changed template affects diagnostics + affectedFiles.add(sourceFile); + break; + } + } + } + + this.#state = new AngularCompilationState( + angularProgram, + host, + typeScriptProgram, + affectedFiles, + affectedFiles.size === 1 ? OptimizeFor.SingleFile : OptimizeFor.WholeProgram, + createWorkerTransformer(hostOptions.processWebWorker.bind(hostOptions)), + useTypeScriptTranspilation, + this.#state?.diagnosticCache, + this.#state?.declarationDiagnostics, + filesWithoutDtsSignature, + useFallbackRootNames, + this.#state?.emittedDtsSourceFiles, + ); + + return { + compilerOptions, + referencedFiles, + externalStylesheets: hostOptions.externalStylesheets, + templateUpdates, + componentResourcesDependencies, + warnings, + }; + } + + protected override *collectDiagnostics(modes: DiagnosticModes): Iterable { + assert(this.#state, 'Angular compilation must be initialized prior to collecting diagnostics.'); + const { + affectedFiles, + angularCompiler, + declarationDiagnostics, + diagnosticCache, + templateDiagnosticsOptimization, + typeScriptProgram, + } = this.#state; + + const syntactic = modes & DiagnosticModes.Syntactic; + const semantic = modes & DiagnosticModes.Semantic; + + try { + // Collect program level diagnostics + if (modes & DiagnosticModes.Option) { + yield* typeScriptProgram.getConfigFileParsingDiagnostics(); + yield* angularCompiler.getOptionDiagnostics(); + yield* typeScriptProgram.getOptionsDiagnostics(); + } + if (syntactic) { + yield* typeScriptProgram.getGlobalDiagnostics(); + } + + // Collect source file specific diagnostics + for (const sourceFile of typeScriptProgram.getSourceFiles()) { + if (angularCompiler.ignoreForDiagnostics.has(sourceFile)) { + continue; + } + + if (syntactic) { + // TypeScript will use cached diagnostics for files that have not been + // changed or affected for this build when using incremental building. + yield* profileSync( + 'NG_DIAGNOSTICS_SYNTACTIC', + () => typeScriptProgram.getSyntacticDiagnostics(sourceFile), + true, + ); + } + + if (!semantic) { + continue; + } + + yield* profileSync( + 'NG_DIAGNOSTICS_SEMANTIC', + () => typeScriptProgram.getSemanticDiagnostics(sourceFile), + true, + ); + + // Declaration files cannot have template or declaration emit diagnostics + if (sourceFile.isDeclarationFile) { + continue; + } + + const fileDeclarationDiagnostics = declarationDiagnostics.get(sourceFile); + if (fileDeclarationDiagnostics) { + yield* fileDeclarationDiagnostics; + } + + // Only request Angular template diagnostics for affected files to avoid + // overhead of template diagnostics for unchanged files. + if (affectedFiles.has(sourceFile)) { + const angularDiagnostics = profileSync( + 'NG_DIAGNOSTICS_TEMPLATE', + () => + angularCompiler.getDiagnosticsForFile(sourceFile, templateDiagnosticsOptimization), + true, + ); + diagnosticCache.set(sourceFile, angularDiagnostics); + yield* angularDiagnostics; + } else { + const angularDiagnostics = diagnosticCache.get(sourceFile); + if (angularDiagnostics) { + yield* angularDiagnostics; + } + } + } + } finally { + // Angular's template typechecker lazily creates `.ngtypecheck.ts` shims in a new `ts.Program` + // during `getDiagnosticsForFile`. Sync the builder program with `angularCompiler.getCurrentProgram()` + // so the next incremental build does not treat all `.ngtypecheck.ts` files as newly added files. + const currentTsProgram = angularCompiler.getCurrentProgram(); + if (currentTsProgram !== typeScriptProgram.getProgram()) { + ensureSourceFileVersions(currentTsProgram, typeScriptProgram); + this.#state.typeScriptProgram = ts.createEmitAndSemanticDiagnosticsBuilderProgram( + currentTsProgram, + this.#state.compilerHost, + typeScriptProgram, + ); + + // Temporarily enable `isolatedModules` and `assumeChangesOnlyAffectDirectDependencies` while + // draining the newly created `.ngtypecheck.ts` files. Because each component `.ts` file + // references its `.ngtypecheck.ts` shim via `sourceFile.referencedFiles`, TypeScript's + // `referencedMap` treats the component `.ts` file as depending on its `.ngtypecheck.ts` shim. + // Without both flags (whether `isolatedModules` is enabled or disabled in `tsconfig`), + // TypeScript would walk from `.ngtypecheck.ts` to the component `.ts` file and its transitive + // importers, resetting their `.d.ts` shape signatures and queueing them into + // `affectedFilesPendingEmit` for the next incremental rebuild. + const compilerOptions = currentTsProgram.getCompilerOptions(); + const prevIsolatedModules = compilerOptions.isolatedModules; + const prevAssumeDirect = compilerOptions.assumeChangesOnlyAffectDirectDependencies; + compilerOptions.isolatedModules = true; + compilerOptions.assumeChangesOnlyAffectDirectDependencies = true; + + const baseEmit = currentTsProgram.emit; + currentTsProgram.emit = () => ({ emitSkipped: true, diagnostics: [] }); + try { + while (this.#state.typeScriptProgram.emitNextAffectedFile(() => {})) { + /* empty */ + } + } finally { + currentTsProgram.emit = baseEmit; + compilerOptions.isolatedModules = prevIsolatedModules; + compilerOptions.assumeChangesOnlyAffectDirectDependencies = prevAssumeDirect; + } + } + } + } + + override emitAffectedFiles(): Iterable { + assert(this.#state, 'Angular compilation must be initialized prior to emitting files.'); + const { + angularCompiler, + compilerHost, + declarationDiagnostics, + emittedDtsSourceFiles, + typeScriptProgram, + webWorkerTransform, + useTypeScriptTranspilation, + } = this.#state; + const compilerOptions = typeScriptProgram.getCompilerOptions(); + const emitDeclarations = !!compilerOptions.declaration; + const buildInfoFilename = compilerOptions.tsBuildInfoFile ?? '.tsbuildinfo'; + + const emittedFiles = new Map(); + const emittedSourceFiles = new Set(); + const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => { + if (!sourceFiles?.length && filename.endsWith(buildInfoFilename)) { + // Save builder info contents to specified location + compilerHost.writeFile(filename, contents, false); + + return; + } + + assert(sourceFiles?.length === 1, 'Invalid TypeScript program emit for ' + filename); + const sourceFile = ts.getOriginalNode(sourceFiles[0], ts.isSourceFile); + if (angularCompiler.ignoreForEmit.has(sourceFile)) { + return; + } + + const isMap = filename.endsWith('.map'); + const isDts = !isMap && /\.d\.[cm]?ts$/i.test(filename); + if (!emitDeclarations || (!isMap && !isDts)) { + angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile); + emittedSourceFiles.add(sourceFile); + } else if (isDts) { + emittedDtsSourceFiles.add(sourceFile); + } + + const targetFilename = emitDeclarations ? filename : sourceFile.fileName; + emittedFiles.set(targetFilename, { filename: targetFilename, contents }); + }; + const transformers = angularCompiler.prepareEmit().transformers; + if (!emitDeclarations) { + transformers.before ??= []; + transformers.before.push( + replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker()), + webWorkerTransform, + ); + + if (!this.browserOnlyBuild) { + transformers.before.push(lazyRoutesTransformer(compilerOptions, compilerHost)); + } + } else if (transformers.before?.length && transformers.afterDeclarations?.length) { + ensurePreDeclarationTransformers(transformers); + } + + // Emit is handled in write file callback when using TypeScript + if (useTypeScriptTranspilation) { + // TypeScript will loop until there are no more affected files in the program + while (true) { + const affectedEmitResult = typeScriptProgram.emitNextAffectedFile( + writeFileCallback, + undefined, + undefined, + transformers, + ); + if (!affectedEmitResult) { + break; + } + if ('kind' in affectedEmitResult.affected) { + declarationDiagnostics.set( + affectedEmitResult.affected, + affectedEmitResult.result.diagnostics, + ); + } + } + } else if (compilerOptions.tsBuildInfoFile) { + // Manually get the builder state for the persistent cache + // The TypeScript API currently embeds this behavior inside the program emit + // via emitNextAffectedFile but that also applies all internal transforms. + const programWithGetState = typeScriptProgram.getProgram() as ts.Program & { + emitBuildInfo(writeFileCallback?: ts.WriteFileCallback): void; + }; + + assert( + typeof programWithGetState.emitBuildInfo === 'function', + 'TypeScript program emitBuildInfo is missing.', + ); + + programWithGetState.emitBuildInfo(); + } + + // Angular may have files that must be emitted but TypeScript does not consider affected + for (const sourceFile of typeScriptProgram.getSourceFiles()) { + if (emittedSourceFiles.has(sourceFile) || angularCompiler.ignoreForEmit.has(sourceFile)) { + continue; + } + + if (sourceFile.isDeclarationFile) { + continue; + } + + if (angularCompiler.incrementalCompilation.safeToSkipEmit(sourceFile)) { + continue; + } + + if (useTypeScriptTranspilation) { + const hasEmittedDts = emittedDtsSourceFiles.has(sourceFile); + const emitOnly = hasEmittedDts ? (EmitOnly.Js as unknown as boolean) : undefined; + const emitResult = typeScriptProgram.emit( + sourceFile, + writeFileCallback, + undefined, + emitOnly, + transformers, + ); + if (!hasEmittedDts) { + declarationDiagnostics.set(sourceFile, emitResult.diagnostics); + } + continue; + } + + // When not using TypeScript transpilation, directly apply only Angular specific transformations + const transformResult = ts.transform( + sourceFile, + [ + ...(transformers.before ?? []), + ...(transformers.after ?? []), + ] as ts.TransformerFactory[], + compilerOptions, + ); + + assert( + transformResult.transformed.length === 1, + 'TypeScript transforms should not produce multiple outputs for ' + sourceFile.fileName, + ); + + let contents: string; + if (sourceFile === transformResult.transformed[0]) { + // Use original content if no changes were made + contents = sourceFile.text; + } else { + // Otherwise, print the transformed source file with map if needed + const printer = ts.createPrinter(compilerOptions, transformResult); + const printResult = printSourceFileWithMap( + transformResult.transformed[0], + printer, + compilerHost, + compilerOptions, + ); + + contents = printResult.code; + + if (printResult.map) { + if (compilerOptions.inlineSourceMap) { + const base64Map = Buffer.from(printResult.map).toString('base64'); + contents += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map}`; + } else if (compilerOptions.sourceMap) { + const mapFilename = sourceFile.fileName + '.map'; + emittedFiles.set(mapFilename, { filename: mapFilename, contents: printResult.map }); + } + } + } + + angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile); + emittedSourceFiles.add(sourceFile); + emittedFiles.set(sourceFile.fileName, { filename: sourceFile.fileName, contents }); + } + + return emittedFiles.values(); + } +} + +/** + * When TypeScript's `BuilderProgram.emitNextAffectedFile` emits `.d.ts`-only (`emitOnly = Dts`) + * for a transitive importer, TypeScript skips `transformers.before`. However, Angular's + * `declarationTransformFactory` in `afterDeclarations` relies on `ivyTransformFactory` in + * `before` having compiled the file's classes to populate `dtsTransforms`. + * + * This helper ensures `before` transformers have executed on the original `SourceFile` prior to + * `afterDeclarations`, and caches the transformed `SourceFile` so a subsequent `.js` emit for the + * same file does not re-execute `before` transformers. + */ +export function ensurePreDeclarationTransformers(transformers: ts.CustomTransformers): void { + if (!transformers.before?.length || !transformers.afterDeclarations?.length) { + return; + } + + const beforeTransformers = transformers.before as ts.TransformerFactory[]; + const seenSourceFiles = new Set(); + const preTransformedSourceFiles = new Map(); + const runBefore = ( + beforeTransforms: ts.Transformer[], + sourceFile: ts.SourceFile, + originalSourceFile: ts.SourceFile, + ): ts.SourceFile => { + seenSourceFiles.add(originalSourceFile); + let current = sourceFile; + for (const transform of beforeTransforms) { + current = transform(current); + } + + return current; + }; + + transformers.before = [ + (context) => { + let beforeTransforms: ts.Transformer[] | undefined; + + return (sourceFile) => { + const originalSourceFile = ts.getOriginalNode(sourceFile, ts.isSourceFile); + const preTransformed = preTransformedSourceFiles.get(originalSourceFile); + if (preTransformed !== undefined) { + preTransformedSourceFiles.delete(originalSourceFile); + + return preTransformed; + } + + beforeTransforms ??= beforeTransformers.map((factory) => factory(context)); + + return runBefore(beforeTransforms, sourceFile, originalSourceFile); + }; + }, + ]; + transformers.afterDeclarations = [ + (context) => { + let beforeTransforms: ts.Transformer[] | undefined; + + return (fileOrBundle) => { + if (!ts.isBundle(fileOrBundle)) { + const originalSourceFile = ts.getOriginalNode(fileOrBundle, ts.isSourceFile); + if (!seenSourceFiles.has(originalSourceFile)) { + beforeTransforms ??= beforeTransformers.map((factory) => factory(context)); + preTransformedSourceFiles.set( + originalSourceFile, + runBefore(beforeTransforms, originalSourceFile, originalSourceFile), + ); + } + } + + return fileOrBundle; + }; + }, + ...transformers.afterDeclarations, + ]; +} + +function findAffectedFiles( + builder: ts.EmitAndSemanticDiagnosticsBuilderProgram, + { ignoreForDiagnostics, incrementalCompilation }: ng.NgtscProgram['compiler'], + includeTTC: boolean, + oldBuilder: ts.EmitAndSemanticDiagnosticsBuilderProgram | undefined, + filesWithoutDtsSignature: Set, +): Set { + const affectedFiles = new Set(); + + // Temporarily disable `isolatedModules` and `verbatimModuleSyntax` while walking affected files. + // In TypeScript, `getIsolatedModules(options)` is true when either `isolatedModules` or + // `verbatimModuleSyntax` is enabled, which causes `getFilesAffectedByUpdatedShapeWhenModuleEmit` + // to short-circuit and return only `[sourceFileWithUpdatedShape]` instead of walking + // `referencedMap` during `getSemanticDiagnosticsOfNextAffectedFile`. + // Disabling both flags forces TypeScript to traverse `referencedMap` when a file's `.d.ts` shape + // signature changes so that dependent files—especially `.ngtypecheck.ts` shims that reference + // directives or pipes used in templates—are visited by the `ignoreSourceFile` callback and have + // their own `.d.ts` shape signatures updated along the dependency chain. + const compilerOptions = builder.getCompilerOptions(); + const prevIsolatedModules = compilerOptions.isolatedModules; + const prevVerbatimModuleSyntax = compilerOptions.verbatimModuleSyntax; + compilerOptions.isolatedModules = false; + compilerOptions.verbatimModuleSyntax = false; + + const program = builder.getProgram(); + const baseEmit = program.emit; + const oldProgram = oldBuilder?.getProgram(); + const hadDtsSignatureComputed = new Set(); + const tsAffectedFiles: ts.SourceFile[] = []; + program.emit = ( + targetSourceFile, + writeFile, + cancellationToken, + emitOnly, + customTransformers, + ...rest + ) => { + if (targetSourceFile) { + if (isNgTypeCheckFile(targetSourceFile.fileName)) { + return { emitSkipped: true, diagnostics: [] }; + } + + if (oldProgram && filesWithoutDtsSignature.has(targetSourceFile.fileName)) { + const oldSourceFile = oldProgram.getSourceFile(targetSourceFile.fileName); + if (oldSourceFile) { + let oldDtsText: string | undefined; + let oldHasDiagnostics = false; + oldProgram.emit( + oldSourceFile, + (_fileName, text, _bom, _onError, _sourceFiles, data) => { + oldDtsText = text; + oldHasDiagnostics = !!(data as { diagnostics?: unknown[] } | undefined)?.diagnostics + ?.length; + }, + cancellationToken, + emitOnly, + customTransformers, + ...(rest as []), + ); + + return baseEmit.call( + program, + targetSourceFile, + (fileName, text, writeByteOrderMark, onError, sourceFiles, data) => { + const newHasDiagnostics = !!(data as { diagnostics?: unknown[] } | undefined) + ?.diagnostics?.length; + if (text !== oldDtsText || oldHasDiagnostics || newHasDiagnostics) { + filesWithoutDtsSignature.delete(targetSourceFile.fileName); + hadDtsSignatureComputed.add(targetSourceFile.fileName); + writeFile?.(fileName, text, writeByteOrderMark, onError, sourceFiles, data); + } + }, + cancellationToken, + emitOnly, + customTransformers, + ...(rest as []), + ); + } + + filesWithoutDtsSignature.delete(targetSourceFile.fileName); + } + + hadDtsSignatureComputed.add(targetSourceFile.fileName); + } + + return baseEmit.call( + program, + targetSourceFile, + writeFile, + cancellationToken, + emitOnly, + customTransformers, + ...(rest as []), + ); + }; + + try { + while ( + builder.getSemanticDiagnosticsOfNextAffectedFile(undefined, (sourceFile) => { + if (ignoreForDiagnostics.has(sourceFile)) { + // If the affected file is a TTC shim, add the shim's original source file. + // This ensures that changes that affect TTC are typechecked even when the changes + // are otherwise unrelated from a TS perspective and do not result in Ivy codegen changes. + // For example, changing @Input property types of a directive used in another component's + // template. + // A TTC shim is a file that has been ignored for diagnostics and has a filename ending in `.ngtypecheck.ts`. + if (isNgTypeCheckFile(sourceFile.fileName)) { + // This file name conversion relies on internal compiler logic and should be converted + // to an official method when available. + const originalFilename = getOriginalFilename(sourceFile.fileName); + const originalSourceFile = builder.getSourceFile(originalFilename); + if (originalSourceFile) { + affectedFiles.add(originalSourceFile); + } + } + } else { + affectedFiles.add(sourceFile); + tsAffectedFiles.push(sourceFile); + } + + return true; + }) + ) { + /* empty */ + } + } finally { + program.emit = baseEmit; + compilerOptions.isolatedModules = prevIsolatedModules; + compilerOptions.verbatimModuleSyntax = prevVerbatimModuleSyntax; + } + + // Transitive importers visited by `getSemanticDiagnosticsOfNextAffectedFile` via + // `handleDtsMayChangeOf` do not have a `.d.ts` signature computed via `program.emit` and + // instead have their BuilderProgram signature reset to `sourceFile.version`. Track them so + // `populatePreviousDtsSignatures` restores their `.d.ts` signature if they are modified later. + for (const sourceFile of tsAffectedFiles) { + if (!sourceFile.isDeclarationFile && !hadDtsSignatureComputed.has(sourceFile.fileName)) { + filesWithoutDtsSignature.add(sourceFile.fileName); + } + } + + // Also include files identified by Angular's incremental compilation graph as needing + // re-emission or template re-typechecking (such as selector renames or required input changes + // that only affect Angular metadata without altering standard TypeScript `.d.ts` signatures). + for (const sourceFile of builder.getSourceFiles()) { + if (sourceFile.isDeclarationFile || ignoreForDiagnostics.has(sourceFile)) { + continue; + } + + if ( + !incrementalCompilation.safeToSkipEmit(sourceFile) || + incrementalCompilation.priorTypeCheckingResultsFor(sourceFile) === null + ) { + affectedFiles.add(sourceFile); + } + } + + // Add all files with associated template type checking files. + // Stored TS build info does not have knowledge of the AOT compiler or the typechecking state of the templates. + // To ensure that errors are reported correctly, all AOT component diagnostics need to be analyzed even if build + // info is present. + if (includeTTC) { + for (const sourceFile of builder.getSourceFiles()) { + if (ignoreForDiagnostics.has(sourceFile) && isNgTypeCheckFile(sourceFile.fileName)) { + // This file name conversion relies on internal compiler logic and should be converted + // to an official method when available. + const originalFilename = getOriginalFilename(sourceFile.fileName); + const originalSourceFile = builder.getSourceFile(originalFilename); + if (originalSourceFile) { + affectedFiles.add(originalSourceFile); + } + } + } + } + + return affectedFiles; +} + +/** + * Ensures `.d.ts` shape signatures are computed on the previous `BuilderProgram` for modified + * files before the next `BuilderProgram` is created. + * + * On the initial build (`oldProgram === undefined`), TypeScript initializes each file's shape + * signature to its `.ts` source version hash rather than emitting `.d.ts` declarations for every + * file. When a file is modified on a watch rebuild, TypeScript computes its new `.d.ts` signature + * and compares it against the previous program's stored signature. Emitting the `.d.ts` signature + * on `oldProgram` for the modified file replaces its initial `.ts` version hash with its true + * `.d.ts` signature prior to diffing, avoiding false-positive `.d.ts` cascades across importers. + */ +function populatePreviousDtsSignatures( + oldProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram, + modifiedFiles: ReadonlySet, + filesWithoutDtsSignature: Set, +): void { + const filesToUpdate: ts.SourceFile[] = []; + for (const modifiedFile of modifiedFiles) { + const sf = oldProgram.getSourceFile(modifiedFile); + if (sf && !sf.isDeclarationFile && filesWithoutDtsSignature.delete(sf.fileName)) { + filesToUpdate.push(sf); + } + } + + if (filesToUpdate.length === 0) { + return; + } + + const compilerOptions = oldProgram.getCompilerOptions(); + const prevDeclaration = compilerOptions.declaration; + const prevComposite = compilerOptions.composite; + const prevNoEmit = compilerOptions.noEmit; + const prevNoEmitOnError = compilerOptions.noEmitOnError; + + compilerOptions.declaration = true; + compilerOptions.composite = false; + compilerOptions.noEmit = false; + compilerOptions.noEmitOnError = false; + + const program = oldProgram.getProgram(); + const baseEmit = program.emit as ( + targetSourceFile?: ts.SourceFile, + writeFile?: ts.WriteFileCallback, + cancellationToken?: ts.CancellationToken, + emitOnlyDtsFiles?: boolean | EmitOnly, + customTransformers?: ts.CustomTransformers, + forceDtsEmit?: boolean, + ) => ts.EmitResult; + program.emit = (targetSourceFile, writeFile, cancellationToken) => + baseEmit.call( + program, + targetSourceFile, + writeFile, + cancellationToken, + EmitOnly.BuilderSignature, + undefined, + true, // forceDtsEmit + ); + + try { + for (const sf of filesToUpdate) { + oldProgram.emit(sf, () => {}, undefined, true); + } + } finally { + program.emit = baseEmit; + compilerOptions.declaration = prevDeclaration; + compilerOptions.composite = prevComposite; + compilerOptions.noEmit = prevNoEmit; + compilerOptions.noEmitOnError = prevNoEmitOnError; + } +} + +/** + * Checks whether the analyzed Angular program contains any non-standalone `@Component` + * declarations in user source files that lack an owning `NgModule` scope. + */ +function hasUnscopedComponent(angularCompiler: ng.NgCompiler): boolean { + // `incrementalCompilation.state` is typed as the `IncrementalState` union + // (`AnalyzedIncrementalState | DeltaIncrementalState | FreshIncrementalState`), where only + // `AnalyzedIncrementalState` (populated once `analyzeAsync()` completes) contains + // `priorAnalysis`. Because the `IncrementalStateKind` discriminant enum is not re-exported + // from `@angular/compiler-cli`, narrow the union via `'priorAnalysis' in state`. + const state = angularCompiler.incrementalCompilation.state; + if (!('priorAnalysis' in state)) { + return false; + } + + let templateTypeChecker: ReturnType | undefined; + + for (const [sourceFile, records] of state.priorAnalysis) { + if (records.length === 0 || angularCompiler.ignoreForEmit.has(sourceFile)) { + continue; + } + + for (const { node } of records) { + if (!ts.isClassDeclaration(node)) { + continue; + } + + const meta = angularCompiler.getMeta(node); + if (meta && 'isComponent' in meta && meta.isComponent && !meta.isStandalone) { + if (!templateTypeChecker) { + // `NgtscProgram` initializes `NgCompiler` with `enableTemplateTypeChecker = false`, which + // guards the public `getTemplateTypeChecker()` getter. Temporarily enable the flag after + // `analyzeAsync()` has already constructed `TemplateTypeCheckerImpl` so we can query + // `getOwningNgModule()` without altering `typeCheckingConfig.enableTemplateTypeChecker`. + const compiler = angularCompiler as { enableTemplateTypeChecker: boolean }; + const previous = compiler.enableTemplateTypeChecker; + try { + compiler.enableTemplateTypeChecker = true; + templateTypeChecker = angularCompiler.getTemplateTypeChecker(); + } finally { + compiler.enableTemplateTypeChecker = previous; + } + } + + if (templateTypeChecker.getOwningNgModule(node) === null) { + return true; + } + } + } + } + + return false; +} diff --git a/packages/angular/build/src/tools/angular/compilation/compiler-options.ts b/packages/angular/build/src/tools/angular/compilation/compiler-options.ts new file mode 100644 index 000000000000..87a7c7822fc3 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/compiler-options.ts @@ -0,0 +1,166 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type * as ng from '@angular/compiler-cli'; +import type { PartialMessage } from 'esbuild'; +import * as path from 'node:path'; +import type ts from 'typescript'; +import { toPosixPath } from '../../../utils/path'; + +export interface CompilerOptionOverrides { + sourcemap?: boolean; + preserveSymlinks?: boolean; + cachePath?: string; + externalRuntimeStyles?: boolean; + enableHmr?: boolean; + instrumentForCoverage?: boolean; + includeTestMetadata?: boolean; + customConditions?: string[]; + rootFiles?: string[]; + declarationMap?: boolean; + compilationMode?: 'full' | 'partial'; + paths?: Record; + basePath?: string; +} + +export function transformCompilerOptions( + typeScript: typeof ts, + baseCompilerOptions: ng.CompilerOptions, + overrides?: CompilerOptionOverrides, + tsconfig?: string, + buildType: 'application' | 'library' = 'application', +): { compilerOptions: ng.CompilerOptions; warnings: PartialMessage[] } { + const compilerOptions = { ...baseCompilerOptions }; + const warnings: PartialMessage[] = []; + const isLibrary = buildType === 'library'; + + if (isLibrary) { + compilerOptions.target = typeScript.ScriptTarget.ES2022; + compilerOptions.module = typeScript.ModuleKind.Preserve; + compilerOptions.importHelpers = true; + compilerOptions.declaration = true; + compilerOptions.declarationMap = !!( + overrides?.declarationMap ?? compilerOptions.declarationMap + ); + compilerOptions.declarationDir = undefined; + } + + if ( + compilerOptions.target === undefined || + compilerOptions.target < typeScript.ScriptTarget.ES2022 + ) { + // If 'useDefineForClassFields' is already defined in the users project leave the value as is. + // Otherwise fallback to false due to https://github.com/microsoft/TypeScript/issues/45995 + // which breaks the deprecated `@Effects` NGRX decorator and potentially other existing code as well. + compilerOptions.target = typeScript.ScriptTarget.ES2022; + compilerOptions.useDefineForClassFields ??= false; + + warnings.push({ + text: + `TypeScript compiler options 'target' and 'useDefineForClassFields' are set to 'ES2022' and ` + + `'false' respectively by the Angular CLI.`, + location: tsconfig ? { file: tsconfig } : null, + notes: [ + { + text: + 'To control ECMA version and features use the Browserslist configuration. ' + + 'For more information, see https://angular.dev/tools/cli/build#configuring-browser-compatibility', + }, + ], + }); + } + + if (!isLibrary && compilerOptions.compilationMode === 'partial') { + warnings.push({ + text: 'Angular partial compilation mode is not supported when building applications.', + location: null, + notes: [{ text: 'Full compilation mode will be used instead.' }], + }); + compilerOptions.compilationMode = 'full'; + } else if (overrides?.compilationMode) { + compilerOptions.compilationMode = overrides.compilationMode; + } else if (isLibrary) { + compilerOptions.compilationMode ??= 'partial'; + } + + // Enable incremental compilation by default if caching is enabled and incremental is not explicitly disabled + if (compilerOptions.incremental !== false && overrides?.cachePath) { + compilerOptions.incremental = true; + // Set the build info file location to the configured cache directory + compilerOptions.tsBuildInfoFile = path.join(overrides.cachePath, '.tsbuildinfo'); + } else { + compilerOptions.incremental = false; + } + + if ( + compilerOptions.module === undefined || + compilerOptions.module < typeScript.ModuleKind.ES2015 + ) { + compilerOptions.module = typeScript.ModuleKind.ES2022; + warnings.push({ + text: `TypeScript compiler options 'module' values 'CommonJS', 'UMD', 'System' and 'AMD' are not supported.`, + location: null, + notes: [{ text: `The 'module' option will be set to 'ES2022' instead.` }], + }); + } + + if (compilerOptions.isolatedModules && compilerOptions.emitDecoratorMetadata) { + warnings.push({ + text: `TypeScript compiler option 'isolatedModules' may prevent the 'emitDecoratorMetadata' option from emitting all metadata.`, + location: null, + notes: [ + { + text: + `The 'emitDecoratorMetadata' option is not required by Angular ` + + 'and can be removed if not explicitly required by the project.', + }, + ], + }); + } + + // Synchronize custom resolve conditions. + // Set if using the supported bundler resolution mode (bundler is the default in new projects) + if ( + compilerOptions.moduleResolution === typeScript.ModuleResolutionKind.Bundler || + compilerOptions.module === typeScript.ModuleKind.Preserve + ) { + compilerOptions.customConditions = overrides?.customConditions; + } + + return { + compilerOptions: { + ...compilerOptions, + basePath: overrides?.basePath ? toPosixPath(overrides.basePath) : compilerOptions.basePath, + noEmit: false, + noEmitOnError: false, + emitDeclarationOnly: false, + composite: false, + inlineSources: !!overrides?.sourcemap, + inlineSourceMap: !isLibrary && !!overrides?.sourcemap, + sourceMap: isLibrary ? !!overrides?.sourcemap : undefined, + mapRoot: undefined, + sourceRoot: undefined, + preserveSymlinks: overrides?.preserveSymlinks, + externalRuntimeStyles: overrides?.externalRuntimeStyles, + _enableHmr: !!overrides?.enableHmr, + // TypeScript transpilation is forced if: + // - Building a library (TypeScript emits both .js and .d.ts in a single pass). + // - isolatedModules is disabled (TS needs full module types to emit JS). + // - Karma code coverage is active (the coverage instrumentation transformer is Babel-based + // and cannot parse raw TypeScript code; Vitest handles coverage instrumentation downstream). + _useTypeScriptTranspilation: + isLibrary || !compilerOptions.isolatedModules || !!overrides?.instrumentForCoverage, + supportTestBed: isLibrary ? undefined : !!overrides?.includeTestMetadata, + supportJitMode: isLibrary ? undefined : !!overrides?.includeTestMetadata, + paths: overrides?.paths + ? { ...baseCompilerOptions.paths, ...overrides.paths } + : baseCompilerOptions.paths, + }, + warnings, + }; +} diff --git a/packages/angular/build/src/tools/angular/compilation/diagnostics.ts b/packages/angular/build/src/tools/angular/compilation/diagnostics.ts new file mode 100644 index 000000000000..41563b90ebbd --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/diagnostics.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { PartialMessage, PartialNote } from 'esbuild'; +import { platform } from 'node:os'; +import type ts from 'typescript'; + +/** + * Converts TypeScript Diagnostic related information into an esbuild compatible note object. + * Related information is a subset of a full TypeScript Diagnostic and also used for diagnostic + * notes associated with the main Diagnostic. + * @param info The TypeScript diagnostic relative information to convert. + * @returns An esbuild diagnostic message as a PartialMessage object + */ +function convertTypeScriptDiagnosticInfo( + typescript: typeof ts, + info: ts.DiagnosticRelatedInformation, + textPrefix?: string, +): PartialNote { + const newLine = platform() === 'win32' ? '\r\n' : '\n'; + let text = typescript.flattenDiagnosticMessageText(info.messageText, newLine); + if (textPrefix) { + text = textPrefix + text; + } + + const note: PartialNote = { text }; + + if (info.file) { + note.location = { + file: info.file.fileName, + length: info.length, + }; + + // Calculate the line/column location and extract the full line text that has the diagnostic + if (info.start !== undefined) { + const { line, character } = typescript.getLineAndCharacterOfPosition(info.file, info.start); + note.location.line = line + 1; + note.location.column = character; + + // The start position for the slice is the first character of the error line + const lineStartPosition = typescript.getPositionOfLineAndCharacter(info.file, line, 0); + + // The end position for the slice is the first character of the next line or the length of + // the entire file if the line is the last line of the file (getPositionOfLineAndCharacter + // will error if a nonexistent line is passed). + const { line: lastLineOfFile } = typescript.getLineAndCharacterOfPosition( + info.file, + Math.max(0, info.file.text.length - 1), + ); + const lineEndPosition = + line < lastLineOfFile + ? typescript.getPositionOfLineAndCharacter(info.file, line + 1, 0) + : info.file.text.length; + + note.location.lineText = info.file.text.slice(lineStartPosition, lineEndPosition).trimEnd(); + } + } + + return note; +} + +/** + * Converts a TypeScript Diagnostic message into an esbuild compatible message object. + * @param diagnostic The TypeScript diagnostic to convert. + * @returns An esbuild diagnostic message as a PartialMessage object + */ +export function convertTypeScriptDiagnostic( + typescript: typeof ts, + diagnostic: ts.Diagnostic, +): PartialMessage { + let codePrefix = 'TS'; + let code = `${diagnostic.code}`; + + // Custom ngtsc diagnostics are prefixed with -99 which isn't a valid TypeScript diagnostic code. + // Strip it and mark the diagnostic as coming from Angular. Note that we can't rely on + // `diagnostic.source`, because it isn't always produced. This is identical to: + // https://github.com/angular/angular/blob/main/packages/compiler-cli/src/ngtsc/diagnostics/src/util.ts + if (code.startsWith('-99')) { + codePrefix = 'NG'; + code = code.slice(3); + } + + const message: PartialMessage = convertTypeScriptDiagnosticInfo( + typescript, + diagnostic, + `${codePrefix}${code}: `, + ); + + if (diagnostic.relatedInformation?.length) { + message.notes = diagnostic.relatedInformation.map((info) => + convertTypeScriptDiagnosticInfo(typescript, info), + ); + } + + return message; +} diff --git a/packages/angular/build/src/tools/angular/compilation/diagnostics_spec.ts b/packages/angular/build/src/tools/angular/compilation/diagnostics_spec.ts new file mode 100644 index 000000000000..8ea79c20ba43 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/diagnostics_spec.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import ts from 'typescript'; +import { convertTypeScriptDiagnostic } from './diagnostics'; + +describe('convertTypeScriptDiagnostic', () => { + it('populates line, column, and lineText when diagnostic or relatedInformation starts at offset 0', () => { + const sourceFile = ts.createSourceFile( + '/src/app/app.component.ts', + 'const invalid: number = "string";\nconst secondLine = 2;\n', + ts.ScriptTarget.Latest, + ); + const templateFile = ts.createSourceFile( + '/src/app/app.component.html', + '\n
Valid
\n', + ts.ScriptTarget.Latest, + ); + + const diagnostic: ts.Diagnostic = { + category: ts.DiagnosticCategory.Error, + code: -998001, + file: templateFile, + start: 0, + length: 17, + messageText: "'unknown-element' is not a known element.", + relatedInformation: [ + { + category: ts.DiagnosticCategory.Message, + code: 0, + file: sourceFile, + start: 0, + length: 5, + messageText: 'Error occurs in the template of component AppComponent.', + }, + ], + }; + + const result = convertTypeScriptDiagnostic(ts, diagnostic); + + expect(result.text).toBe("NG8001: 'unknown-element' is not a known element."); + expect(result.location).toEqual({ + file: '/src/app/app.component.html', + length: 17, + line: 1, + column: 0, + lineText: '', + }); + + expect(result.notes).toEqual([ + { + text: 'Error occurs in the template of component AppComponent.', + location: { + file: '/src/app/app.component.ts', + length: 5, + line: 1, + column: 0, + lineText: 'const invalid: number = "string";', + }, + }, + ]); + }); + + it('does not throw a TypeScript Debug Failure when diagnostic is reported on an empty file', () => { + const emptyFile = ts.createSourceFile('/src/empty.ts', '', ts.ScriptTarget.Latest); + + const diagnosticAtZero: ts.Diagnostic = { + category: ts.DiagnosticCategory.Error, + code: 2306, + file: emptyFile, + start: 0, + length: 0, + messageText: "File '/src/empty.ts' is not a module.", + }; + + expect(() => convertTypeScriptDiagnostic(ts, diagnosticAtZero)).not.toThrow(); + expect(convertTypeScriptDiagnostic(ts, diagnosticAtZero)).toEqual({ + text: "TS2306: File '/src/empty.ts' is not a module.", + location: { + file: '/src/empty.ts', + length: 0, + line: 1, + column: 0, + lineText: '', + }, + }); + + const diagnosticPastEnd: ts.Diagnostic = { + category: ts.DiagnosticCategory.Error, + code: 1005, + file: emptyFile, + start: 1, + length: 0, + messageText: 'Unexpected end of text.', + }; + + expect(() => convertTypeScriptDiagnostic(ts, diagnosticPastEnd)).not.toThrow(); + expect(convertTypeScriptDiagnostic(ts, diagnosticPastEnd).location).toEqual({ + file: '/src/empty.ts', + length: 0, + line: 1, + column: 1, + lineText: '', + }); + }); +}); diff --git a/packages/angular/build/src/tools/angular/compilation/factory.ts b/packages/angular/build/src/tools/angular/compilation/factory.ts new file mode 100644 index 000000000000..3944979f1ee5 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/factory.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { useParallelTs } from '../../../utils/environment-options'; +import type { AngularCompilation } from './angular-compilation'; + +export type AngularCompilationMode = 'aot' | 'jit' | 'transform'; + +/** + * Creates an Angular compilation object that can be used to perform Angular application + * compilation either for AOT, JIT, or on-demand transform mode. By default a parallel compilation is created + * that uses a Node.js worker thread. + * @param mode True or 'jit' for JIT mode; False or 'aot' for AOT compilation; 'transform' for on-demand transformation. + * @param browserOnlyBuild True, for browser only builds; False, for browser and server builds. + * @param parallel True to execute compilation in a worker thread. + * @returns An instance of an Angular compilation object. + */ +export async function createAngularCompilation( + mode: boolean | AngularCompilationMode, + browserOnlyBuild: boolean, + parallel: boolean = useParallelTs, +): Promise { + if (mode === 'transform') { + throw new Error('Transform compilation mode is not supported.'); + } + + const isJit = mode === true || mode === 'jit'; + + if (parallel) { + const { ParallelCompilation } = await import('./parallel-compilation'); + + return new ParallelCompilation(isJit, browserOnlyBuild); + } + + if (isJit) { + const { JitCompilation } = await import('./jit-compilation'); + + return new JitCompilation(browserOnlyBuild); + } else { + const { AotCompilation } = await import('./aot-compilation'); + + return new AotCompilation(browserOnlyBuild); + } +} diff --git a/packages/angular/build/src/tools/angular/compilation/hmr-candidates.ts b/packages/angular/build/src/tools/angular/compilation/hmr-candidates.ts new file mode 100644 index 000000000000..cba0b18e7414 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/hmr-candidates.ts @@ -0,0 +1,347 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type * as ng from '@angular/compiler-cli'; +import assert from 'node:assert'; +import ts from 'typescript'; + +/** + * Analyzes one or more modified files for changes to determine if any + * class declarations for Angular components are candidates for hot + * module replacement (HMR). If any source files are also modified but + * are not candidates then all candidates become invalid. This invalidation + * ensures that a full rebuild occurs and the running application stays + * synchronized with the code. + * @param modifiedFiles A set of modified files to analyze. + * @param param1 An Angular compiler instance + * @param staleSourceFiles A map of paths to previous source file instances. + * @returns A set of HMR candidate component class declarations. + */ +export function collectHmrCandidates( + modifiedFiles: Set, + { compiler }: ng.NgtscProgram, + staleSourceFiles: Map | undefined, +): Set { + const candidates = new Set(); + + for (const file of modifiedFiles) { + // If the file is a template for component(s), add component classes as candidates + const templateFileNodes = compiler.getComponentsWithTemplateFile(file); + if (templateFileNodes.size) { + templateFileNodes.forEach((node) => candidates.add(node as ts.ClassDeclaration)); + continue; + } + + // If the file is a style for component(s), add component classes as candidates + const styleFileNodes = compiler.getComponentsWithStyleFile(file); + if (styleFileNodes.size) { + styleFileNodes.forEach((node) => candidates.add(node as ts.ClassDeclaration)); + continue; + } + + const staleSource = staleSourceFiles?.get(file); + if (staleSource === undefined) { + // Unknown file requires a rebuild so clear out the candidates and stop collecting + candidates.clear(); + break; + } + + const updatedSource = compiler.getCurrentProgram().getSourceFile(file); + if (updatedSource === undefined) { + // No longer existing program file requires a rebuild so clear out the candidates and stop collecting + candidates.clear(); + break; + } + + // Analyze the stale and updated file for changes + const fileCandidates = analyzeFileUpdates(staleSource, updatedSource, compiler); + if (fileCandidates) { + fileCandidates.forEach((node) => candidates.add(node)); + } else { + // Unsupported HMR changes present + // Only template and style literal changes are allowed. + candidates.clear(); + break; + } + } + + return candidates; +} + +/** + * Analyzes the updates of a source file for potential HMR component class candidates. + * A source file can contain candidates if only the Angular component metadata of a class + * has been changed and the metadata changes are only of supported fields. + * @param stale The stale (previous) source file instance. + * @param updated The updated source file instance. + * @param compiler An Angular compiler instance. + * @returns An array of candidate class declarations; or `null` if unsupported changes are present. + */ +function analyzeFileUpdates( + stale: ts.SourceFile, + updated: ts.SourceFile, + compiler: ng.NgtscProgram['compiler'], +): ts.ClassDeclaration[] | null { + if (stale.statements.length !== updated.statements.length) { + return null; + } + + const candidates: ts.ClassDeclaration[] = []; + + for (let i = 0; i < updated.statements.length; ++i) { + const updatedNode = updated.statements[i]; + const staleNode = stale.statements[i]; + + if (ts.isClassDeclaration(updatedNode)) { + if (!ts.isClassDeclaration(staleNode)) { + return null; + } + + // Check class declaration differences (name/heritage/modifiers) + if (updatedNode.name?.text !== staleNode.name?.text) { + return null; + } + if (!equalRangeText(updatedNode.heritageClauses, updated, staleNode.heritageClauses, stale)) { + return null; + } + const updatedModifiers = ts.getModifiers(updatedNode); + const staleModifiers = ts.getModifiers(staleNode); + if ( + updatedModifiers?.length !== staleModifiers?.length || + !updatedModifiers?.every((updatedModifier) => + staleModifiers?.some((staleModifier) => updatedModifier.kind === staleModifier.kind), + ) + ) { + return null; + } + + // Check for component class nodes + const meta = compiler.getMeta(updatedNode); + if (meta?.decorator && (meta as { isComponent?: boolean }).isComponent === true) { + const updatedDecorators = ts.getDecorators(updatedNode); + const staleDecorators = ts.getDecorators(staleNode); + if (!staleDecorators || staleDecorators.length !== updatedDecorators?.length) { + return null; + } + + // TODO: Check other decorators instead of assuming all multi-decorator components are unsupported + if (staleDecorators.length > 1) { + return null; + } + + // Find index of component metadata decorator + const metaDecoratorIndex = updatedDecorators?.indexOf(meta.decorator); + assert( + metaDecoratorIndex !== undefined, + 'Component metadata decorator should always be present on component class.', + ); + const updatedDecoratorExpression = meta.decorator.expression; + assert( + ts.isCallExpression(updatedDecoratorExpression) && + updatedDecoratorExpression.arguments.length === 1, + 'Component metadata decorator should contain a call expression with a single argument.', + ); + + // Check the matching stale index for the component decorator + const staleDecoratorExpression = staleDecorators[metaDecoratorIndex]?.expression; + if ( + !staleDecoratorExpression || + !ts.isCallExpression(staleDecoratorExpression) || + staleDecoratorExpression.arguments.length !== 1 + ) { + return null; + } + + // Check decorator name/expression + // NOTE: This would typically be `Component` but can also be a property expression or some other alias. + // To avoid complex checks, this ensures the textual representation does not change. This has a low chance + // of a false positive if the expression is changed to still reference the `Component` type but has different + // text. However, it is rare for `Component` to not be used directly and additionally unlikely that it would + // be changed between edits. A false positive would also only lead to a difference of a full page reload versus + // an HMR update. + if ( + !equalRangeText( + updatedDecoratorExpression.expression, + updated, + staleDecoratorExpression.expression, + stale, + ) + ) { + return null; + } + + // Compare component meta decorator object literals + const analysis = analyzeMetaUpdates( + staleDecoratorExpression, + stale, + updatedDecoratorExpression, + updated, + ); + if (analysis === MetaUpdateAnalysis.Unsupported) { + return null; + } + + // Compare text of the member nodes to determine if any changes have occurred + if (!equalRangeText(updatedNode.members, updated, staleNode.members, stale)) { + // A change to a member outside a component's metadata is unsupported + return null; + } + + // If all previous class checks passed, this class is supported for HMR updates + if (analysis === MetaUpdateAnalysis.Supported) { + candidates.push(updatedNode); + } + continue; + } + } + + // Compare text of the statement nodes to determine if any changes have occurred + // TODO: Consider expanding this to check semantic updates for each node kind + if (!equalRangeText(updatedNode, updated, staleNode, stale)) { + // A change to a statement outside a component's metadata is unsupported + return null; + } + } + + return candidates; +} + +/** + * The set of Angular component metadata fields that are supported by HMR updates. + */ +const SUPPORTED_FIELD_NAMES = new Set([ + 'template', + 'templateUrl', + 'styles', + 'styleUrl', + 'stylesUrl', +]); + +enum MetaUpdateAnalysis { + Supported, + Unsupported, + None, +} + +/** + * Analyzes the metadata fields of a decorator call expression for unsupported HMR updates. + * Only updates to supported fields can be present for HMR to be viable. + * @param staleCall A call expression instance. + * @param staleSource The source file instance containing the stale call instance. + * @param updatedCall A call expression instance. + * @param updatedSource The source file instance containing the updated call instance. + * @returns A MetaUpdateAnalysis enum value. + */ +function analyzeMetaUpdates( + staleCall: ts.CallExpression, + staleSource: ts.SourceFile, + updatedCall: ts.CallExpression, + updatedSource: ts.SourceFile, +): MetaUpdateAnalysis { + const staleObject = staleCall.arguments[0]; + const updatedObject = updatedCall.arguments[0]; + let hasSupportedUpdate = false; + + if (!ts.isObjectLiteralExpression(staleObject) || !ts.isObjectLiteralExpression(updatedObject)) { + return MetaUpdateAnalysis.Unsupported; + } + + const supportedFields = new Map(); + const unsupportedFields: ts.Node[] = []; + + for (const property of staleObject.properties) { + if (!ts.isPropertyAssignment(property) || ts.isComputedPropertyName(property.name)) { + // Unsupported object literal property + return MetaUpdateAnalysis.Unsupported; + } + + const name = property.name.text; + if (SUPPORTED_FIELD_NAMES.has(name)) { + supportedFields.set(name, property.initializer); + continue; + } + + unsupportedFields.push(property.initializer); + } + + let i = 0; + for (const property of updatedObject.properties) { + if (!ts.isPropertyAssignment(property) || ts.isComputedPropertyName(property.name)) { + // Unsupported object literal property + return MetaUpdateAnalysis.Unsupported; + } + + const name = property.name.text; + if (SUPPORTED_FIELD_NAMES.has(name)) { + const staleInitializer = supportedFields.get(name); + // If the supported field was added or has its content changed, there has been a supported update + if ( + !staleInitializer || + !equalRangeText(property.initializer, updatedSource, staleInitializer, staleSource) + ) { + hasSupportedUpdate = true; + } + // Remove the field entry to allow tracking removed fields + supportedFields.delete(name); + continue; + } + + // Compare in order + if (!equalRangeText(property.initializer, updatedSource, unsupportedFields[i++], staleSource)) { + return MetaUpdateAnalysis.Unsupported; + } + } + + if (i !== unsupportedFields.length) { + return MetaUpdateAnalysis.Unsupported; + } + + // Any remaining supported field indicates a field removal. This is also considered a supported update. + hasSupportedUpdate ||= supportedFields.size > 0; + + return hasSupportedUpdate ? MetaUpdateAnalysis.Supported : MetaUpdateAnalysis.None; +} + +/** + * Compares the text from a provided range in a source file to the text of a range in a second source file. + * The comparison avoids making any intermediate string copies. + * @param firstRange A text range within the first source file. + * @param firstSource A source file instance. + * @param secondRange A text range within the second source file. + * @param secondSource A source file instance. + * @returns true, if the text from both ranges is equal; false, otherwise. + */ +function equalRangeText( + firstRange: ts.ReadonlyTextRange | undefined, + firstSource: ts.SourceFile, + secondRange: ts.ReadonlyTextRange | undefined, + secondSource: ts.SourceFile, +): boolean { + // Check matching undefined values + if (!firstRange || !secondRange) { + return firstRange === secondRange; + } + + // Ensure lengths are equal + const firstLength = firstRange.end - firstRange.pos; + const secondLength = secondRange.end - secondRange.pos; + if (firstLength !== secondLength) { + return false; + } + + // Check each character + for (let i = 0; i < firstLength; ++i) { + const firstChar = firstSource.text.charCodeAt(i + firstRange.pos); + const secondChar = secondSource.text.charCodeAt(i + secondRange.pos); + if (firstChar !== secondChar) { + return false; + } + } + + return true; +} diff --git a/packages/angular/build/src/tools/angular/compilation/index.ts b/packages/angular/build/src/tools/angular/compilation/index.ts new file mode 100644 index 000000000000..268abec678ca --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/index.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export { + AngularCompilation, + type AngularCompilationOptions, + type AngularCompilationResult, + DiagnosticModes, + type EmitFileResult, + type FileTransformResult, +} from './angular-compilation'; +export type { CompilerOptionOverrides } from './compiler-options'; +export { createAngularCompilation, type AngularCompilationMode } from './factory'; diff --git a/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts b/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts new file mode 100644 index 000000000000..ec88987369b0 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type * as ng from '@angular/compiler-cli'; +import assert from 'node:assert'; +import ts from 'typescript'; +import { profileSync } from '../../../utils/profiling'; +import { AngularHostOptions, createAngularCompilerHost } from '../angular-host'; +import { createJitResourceTransformer } from '../transformers/jit-resource-transformer'; +import { lazyRoutesTransformer } from '../transformers/lazy-routes-transformer'; +import { createWorkerTransformer } from '../transformers/web-worker-transformer'; +import { + type AngularCompilationResult, + DiagnosticModes, + type EmitFileResult, +} from './angular-compilation'; +import type { CompilerOptionOverrides } from './compiler-options'; +import { TypeScriptCompilation } from './typescript-compilation'; + +class JitCompilationState { + constructor( + public readonly compilerHost: ng.CompilerHost, + public readonly typeScriptProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram, + public readonly constructorParametersDownlevelTransform: ts.TransformerFactory, + public readonly replaceResourcesTransform: ts.TransformerFactory, + public readonly webWorkerTransform: ts.TransformerFactory, + ) {} +} + +export class JitCompilation extends TypeScriptCompilation { + #state?: JitCompilationState; + + constructor(private readonly browserOnlyBuild: boolean) { + super(); + } + + async initialize( + tsconfig: string, + hostOptions: AngularHostOptions, + compilerOptionOverrides?: CompilerOptionOverrides, + buildType: 'application' | 'library' = 'application', + ): Promise { + // Dynamically load the Angular compiler CLI package + const { constructorParametersDownlevelTransform } = + await import('@angular/compiler-cli/private/tooling'); + + // Load the compiler configuration and transform as needed + const { + compilerOptions, + rootNames, + errors: configurationDiagnostics, + warnings, + tsConfigFiles, + } = await this.loadConfiguration(tsconfig, compilerOptionOverrides, buildType); + + if (hostOptions.modifiedFiles) { + this.invalidateFiles(hostOptions.modifiedFiles); + } + + // Create Angular compiler host + const host = createAngularCompilerHost( + ts, + compilerOptions, + hostOptions, + undefined, + this.sourceFiles, + ); + + // Create the TypeScript Program + const typeScriptProgram = profileSync('TS_CREATE_PROGRAM', () => + ts.createEmitAndSemanticDiagnosticsBuilderProgram( + rootNames, + compilerOptions, + host, + this.#state?.typeScriptProgram ?? ts.readBuilderProgram(compilerOptions, host), + configurationDiagnostics, + ), + ); + + this.#state = new JitCompilationState( + host, + typeScriptProgram, + constructorParametersDownlevelTransform(typeScriptProgram.getProgram()), + createJitResourceTransformer(() => typeScriptProgram.getProgram().getTypeChecker()), + createWorkerTransformer(hostOptions.processWebWorker.bind(hostOptions)), + ); + + const referencedFiles = [ + ...tsConfigFiles, + ...typeScriptProgram.getSourceFiles().map((sourceFile) => sourceFile.fileName), + ]; + + return { compilerOptions, referencedFiles, warnings }; + } + + protected override *collectDiagnostics(modes: DiagnosticModes): Iterable { + assert(this.#state, 'Compilation must be initialized prior to collecting diagnostics.'); + const { typeScriptProgram } = this.#state; + + // Collect program level diagnostics + if (modes & DiagnosticModes.Option) { + yield* typeScriptProgram.getConfigFileParsingDiagnostics(); + yield* typeScriptProgram.getOptionsDiagnostics(); + } + if (modes & DiagnosticModes.Syntactic) { + yield* typeScriptProgram.getGlobalDiagnostics(); + yield* profileSync('NG_DIAGNOSTICS_SYNTACTIC', () => + typeScriptProgram.getSyntacticDiagnostics(), + ); + } + if (modes & DiagnosticModes.Semantic) { + yield* profileSync('NG_DIAGNOSTICS_SEMANTIC', () => + typeScriptProgram.getSemanticDiagnostics(), + ); + } + } + + override emitAffectedFiles(): Iterable { + assert(this.#state, 'Compilation must be initialized prior to emitting files.'); + const { + compilerHost, + typeScriptProgram, + constructorParametersDownlevelTransform, + replaceResourcesTransform, + webWorkerTransform, + } = this.#state; + const compilerOptions = typeScriptProgram.getCompilerOptions(); + const buildInfoFilename = compilerOptions.tsBuildInfoFile ?? '.tsbuildinfo'; + + const emittedFiles: EmitFileResult[] = []; + const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => { + if (!sourceFiles?.length && filename.endsWith(buildInfoFilename)) { + // Save builder info contents to specified location + compilerHost.writeFile(filename, contents, false); + + return; + } + + assert(sourceFiles?.length === 1, 'Invalid TypeScript program emit for ' + filename); + + emittedFiles.push({ filename: sourceFiles[0].fileName, contents }); + }; + const transformers = { + before: [ + replaceResourcesTransform, + constructorParametersDownlevelTransform, + webWorkerTransform, + ], + }; + + if (!this.browserOnlyBuild) { + transformers.before.push(lazyRoutesTransformer(compilerOptions, compilerHost)); + } + + // TypeScript will loop until there are no more affected files in the program + while ( + typeScriptProgram.emitNextAffectedFile(writeFileCallback, undefined, undefined, transformers) + ) { + /* empty */ + } + + return emittedFiles; + } +} diff --git a/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts b/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts new file mode 100644 index 000000000000..60945a758081 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { PartialMessage } from 'esbuild'; +import { createRequire } from 'node:module'; +import { MessageChannel } from 'node:worker_threads'; +import { mergeCumulativeDurations } from '../../../utils/profiling'; +import { WorkerPool } from '../../../utils/worker-pool'; +import type { AngularHostOptions } from '../angular-host'; +import { + AngularCompilation, + AngularCompilationResult, + DiagnosticModes, + EmitFileResult, +} from './angular-compilation'; +import type { CompilerOptionOverrides } from './compiler-options'; + +/** + * An Angular compilation which uses a Node.js Worker thread to load and execute + * the TypeScript and Angular compilers. This allows for longer synchronous actions + * such as semantic and template diagnostics to be calculated in parallel to the + * other aspects of the application bundling process. The worker thread also has + * a separate memory pool which significantly reduces the need for adjusting the + * main Node.js CLI process memory settings with large application code sizes. + */ +export class ParallelCompilation extends AngularCompilation { + readonly #worker: WorkerPool; + #webWorkerChannel?: MessageChannel; + + constructor( + private readonly jit: boolean, + private readonly browserOnlyBuild: boolean, + ) { + super(); + + // TODO: Convert to import.meta usage during ESM transition + const localRequire = createRequire(__filename); + + this.#worker = new WorkerPool({ + maxThreads: 1, + idleTimeout: Infinity, + filename: localRequire.resolve('./parallel-worker'), + }); + } + + override async initialize( + tsconfig: string, + hostOptions: AngularHostOptions, + compilerOptionOverrides?: CompilerOptionOverrides, + buildType: 'application' | 'library' = 'application', + ): Promise { + const stylesheetChannel = new MessageChannel(); + // The request identifier is required because Angular can issue multiple concurrent requests + stylesheetChannel.port1.on( + 'message', + ({ requestId, data, containingFile, stylesheetFile, order, className }) => { + hostOptions + .transformStylesheet(data, containingFile, stylesheetFile, order, className) + .then((value) => stylesheetChannel.port1.postMessage({ requestId, value })) + .catch((error) => stylesheetChannel.port1.postMessage({ requestId, error })); + }, + ); + + this.#webWorkerChannel?.port1.close(); + this.#webWorkerChannel = undefined; + + let webWorkerSignal: Int32Array | undefined; + if (buildType !== 'library') { + // The web worker processing is a synchronous operation and uses shared memory combined with + // the Atomics API to block execution here until a response is received. + const webWorkerChannel = new MessageChannel(); + this.#webWorkerChannel = webWorkerChannel; + const signal = new Int32Array(new SharedArrayBuffer(4)); + webWorkerSignal = signal; + webWorkerChannel.port1.on('message', ({ workerFile, containingFile }) => { + try { + const workerCodeFile = hostOptions.processWebWorker(workerFile, containingFile); + webWorkerChannel.port1.postMessage({ workerCodeFile }); + } catch (error) { + webWorkerChannel.port1.postMessage({ error }); + } finally { + Atomics.store(signal, 0, 1); + Atomics.notify(signal, 0); + } + }); + } + + let success = false; + try { + // Execute the initialize function in the worker thread + const result = await this.#worker.run( + { + fileReplacements: hostOptions.fileReplacements, + tsconfig, + jit: this.jit, + browserOnlyBuild: this.browserOnlyBuild, + compilerOptionOverrides, + buildType, + stylesheetPort: stylesheetChannel.port2, + webWorkerPort: this.#webWorkerChannel?.port2, + webWorkerSignal, + }, + { + name: 'initialize', + transferList: this.#webWorkerChannel + ? [stylesheetChannel.port2, this.#webWorkerChannel.port2] + : [stylesheetChannel.port2], + }, + ); + success = true; + + return result; + } finally { + stylesheetChannel.port1.close(); + if (!success) { + this.#webWorkerChannel?.port1.close(); + this.#webWorkerChannel = undefined; + } + } + } + + override async diagnoseFiles( + modes = DiagnosticModes.All, + ): Promise<{ errors?: PartialMessage[]; warnings?: PartialMessage[] }> { + const { timings, ...result } = await this.#worker.run(modes, { name: 'diagnose' }); + if (timings) { + mergeCumulativeDurations(timings); + } + + return result; + } + + override async emitAffectedFiles(): Promise> { + try { + return await this.#worker.run(undefined, { name: 'emit' }); + } finally { + this.#webWorkerChannel?.port1.close(); + this.#webWorkerChannel = undefined; + } + } + + override update(files: Set): Promise { + return this.#worker.run(files, { name: 'update' }); + } + + override close() { + this.#webWorkerChannel?.port1.close(); + this.#webWorkerChannel = undefined; + + return this.#worker.destroy(); + } +} diff --git a/packages/angular/build/src/tools/angular/compilation/parallel-compilation_spec.ts b/packages/angular/build/src/tools/angular/compilation/parallel-compilation_spec.ts new file mode 100644 index 000000000000..7157251e1ab9 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/parallel-compilation_spec.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { MessageChannel } from 'node:worker_threads'; +import { AotCompilation } from './aot-compilation'; +import { initialize } from './parallel-worker'; + +describe('parallel-worker', () => { + it('uses incrementing numeric request IDs for transformStylesheet and handles omitted webWorkerPort in library builds', async () => { + const receivedRequestIds: unknown[] = []; + let workerResult: string | undefined; + + spyOn(AotCompilation.prototype, 'initialize').and.callFake(async (_tsconfig, hostOptions) => { + const [style1, style2] = await Promise.all([ + hostOptions.transformStylesheet('body {}', '/src/a.ts', '/src/a.css', 0, 'CompA'), + hostOptions.transformStylesheet('div {}', '/src/b.ts', '/src/b.css', 1, 'CompB'), + ]); + expect(style1).toBe('transformed:body {}'); + expect(style2).toBe('transformed:div {}'); + + workerResult = hostOptions.processWebWorker('./my.worker', '/src/a.ts'); + + return { + compilerOptions: {}, + referencedFiles: ['/src/a.ts'], + }; + }); + + const stylesheetChannel = new MessageChannel(); + stylesheetChannel.port1.on('message', ({ requestId, data }) => { + receivedRequestIds.push(requestId); + stylesheetChannel.port1.postMessage({ + requestId, + value: `transformed:${data}`, + }); + }); + + try { + const result = await initialize({ + jit: false, + browserOnlyBuild: false, + tsconfig: '/src/tsconfig.lib.json', + buildType: 'library', + stylesheetPort: stylesheetChannel.port2, + }); + + expect(receivedRequestIds).toEqual([1, 2]); + expect(workerResult).toBe('./my.worker'); + expect(result.referencedFiles).toEqual(['/src/a.ts']); + } finally { + stylesheetChannel.port1.close(); + } + }); +}); diff --git a/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts b/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts new file mode 100644 index 000000000000..dde3df4c0c20 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts @@ -0,0 +1,185 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { PartialMessage } from 'esbuild'; +import assert from 'node:assert'; +import { type MessagePort, receiveMessageOnPort } from 'node:worker_threads'; +import { initializeHash } from '../../../utils/hash'; +import { getAndClearCumulativeDurations } from '../../../utils/profiling'; +import type { + AngularCompilation, + AngularCompilationResult, + DiagnosticModes, +} from './angular-compilation'; +import { AotCompilation } from './aot-compilation'; +import type { CompilerOptionOverrides } from './compiler-options'; +import { JitCompilation } from './jit-compilation'; + +export interface InitRequest { + jit: boolean; + browserOnlyBuild: boolean; + tsconfig: string; + fileReplacements?: Record; + compilerOptionOverrides?: CompilerOptionOverrides; + buildType?: 'application' | 'library'; + stylesheetPort: MessagePort; + webWorkerPort?: MessagePort; + webWorkerSignal?: Int32Array; +} + +let compilation: AngularCompilation | undefined; +let activeWebWorkerPort: MessagePort | undefined; + +const modifiedFiles = new Set(); + +export async function initialize(request: InitRequest): Promise { + activeWebWorkerPort?.close(); + activeWebWorkerPort = request.webWorkerPort; + + const currentModifiedFiles = new Set(modifiedFiles); + modifiedFiles.clear(); + + let success = false; + try { + await initializeHash(); + compilation ??= request.jit + ? new JitCompilation(request.browserOnlyBuild) + : new AotCompilation(request.browserOnlyBuild); + + let nextRequestId = 0; + const stylesheetRequests = new Map< + number, + [(value: string) => void, (reason: Error) => void] + >(); + request.stylesheetPort.on('message', ({ requestId, value, error }) => { + const handlers = stylesheetRequests.get(requestId); + if (handlers) { + stylesheetRequests.delete(requestId); + if (error) { + handlers[1](error); + } else { + handlers[0](value); + } + } + }); + + const { + compilerOptions, + referencedFiles, + externalStylesheets, + templateUpdates, + componentResourcesDependencies, + warnings, + } = await compilation.initialize( + request.tsconfig, + { + fileReplacements: request.fileReplacements, + modifiedFiles: currentModifiedFiles, + transformStylesheet(data, containingFile, stylesheetFile, order, className) { + const requestId = ++nextRequestId; + const resultPromise = new Promise((resolve, reject) => + stylesheetRequests.set(requestId, [resolve, reject]), + ); + + request.stylesheetPort.postMessage({ + requestId, + data, + containingFile, + stylesheetFile, + order, + className, + }); + + return resultPromise; + }, + processWebWorker(workerFile, containingFile) { + if (!request.webWorkerPort || !request.webWorkerSignal) { + return workerFile; + } + + Atomics.store(request.webWorkerSignal, 0, 0); + request.webWorkerPort.postMessage({ workerFile, containingFile }); + + Atomics.wait(request.webWorkerSignal, 0, 0); + const result = receiveMessageOnPort(request.webWorkerPort)?.message; + + if (result?.error) { + throw result.error; + } + + return result?.workerCodeFile ?? workerFile; + }, + }, + request.compilerOptionOverrides, + request.buildType, + ); + + success = true; + + return { + externalStylesheets, + templateUpdates, + referencedFiles, + warnings, + // TODO: Expand? `allowJs`, `isolatedModules`, `sourceMap`, `inlineSourceMap` are the only fields needed currently. + compilerOptions: { + allowJs: compilerOptions.allowJs, + compilationMode: compilerOptions.compilationMode, + declarationMap: compilerOptions.declarationMap, + isolatedModules: compilerOptions.isolatedModules, + sourceMap: compilerOptions.sourceMap, + inlineSourceMap: compilerOptions.inlineSourceMap, + _useTypeScriptTranspilation: compilerOptions['_useTypeScriptTranspilation'] as + boolean | undefined, + }, + componentResourcesDependencies, + }; + } finally { + request.stylesheetPort.close(); + if (!success) { + activeWebWorkerPort?.close(); + activeWebWorkerPort = undefined; + } + } +} + +export async function diagnose(modes: DiagnosticModes): Promise<{ + errors?: PartialMessage[]; + warnings?: PartialMessage[]; + timings?: Record; +}> { + assert(compilation); + + const diagnostics = await compilation.diagnoseFiles(modes); + const timings = getAndClearCumulativeDurations(); + + return { + ...diagnostics, + timings, + }; +} + +export async function emit() { + assert(compilation); + + try { + const files = await compilation.emitAffectedFiles(); + + return [...files]; + } finally { + activeWebWorkerPort?.close(); + activeWebWorkerPort = undefined; + } +} + +export async function update(files: Set): Promise { + for (const file of files) { + modifiedFiles.add(file); + } + await compilation?.update?.(files); +} diff --git a/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts b/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts new file mode 100644 index 000000000000..6601500fbcc9 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts @@ -0,0 +1,183 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type * as ng from '@angular/compiler-cli'; +import type { PartialMessage } from 'esbuild'; +import ts from 'typescript'; +import { canonicalizePath, toPosixPath } from '../../../utils/path'; +import { profileAsync, profileSync } from '../../../utils/profiling'; +import { AngularCompilation, DiagnosticModes } from './angular-compilation'; +import { type CompilerOptionOverrides, transformCompilerOptions } from './compiler-options'; +import { convertTypeScriptDiagnostic } from './diagnostics'; + +export interface TransformedConfiguration { + compilerOptions: ng.CompilerOptions; + rootNames: string[]; + fallbackRootNames?: string[]; + errors: ts.Diagnostic[]; + warnings: PartialMessage[]; + tsConfigFiles: readonly string[]; +} + +export abstract class TypeScriptCompilation extends AngularCompilation { + static #angularCompilerCliModule?: typeof ng; + readonly #extendedConfigCache = new Map(); + + static async loadCompilerCli(): Promise { + TypeScriptCompilation.#angularCompilerCliModule ??= await import('@angular/compiler-cli'); + + return TypeScriptCompilation.#angularCompilerCliModule; + } + + protected async loadConfiguration( + tsconfig: string, + compilerOptionOverrides?: CompilerOptionOverrides, + buildType: 'application' | 'library' = 'application', + ): Promise { + const { readConfiguration } = await TypeScriptCompilation.loadCompilerCli(); + + const { + options: originalCompilerOptions, + rootNames: originalRootNames, + errors, + } = profileSync('NG_READ_CONFIG', () => + readConfiguration( + tsconfig, + { + // Angular specific configuration defaults and overrides to ensure a functioning compilation. + suppressOutputPathCheck: true, + outDir: undefined, + sourceMap: false, + ...(buildType === 'library' + ? { declaration: true } + : { + declaration: false, + declarationMap: false, + }), + allowEmptyCodegenFiles: false, + annotationsAs: 'decorators', + enableResourceInlining: false, + supportTestBed: false, + supportJitMode: false, + // Disable removing of comments as TS is quite aggressive with these and can + // remove important annotations, such as /* @__PURE__ */ and comments like /* vite-ignore */. + removeComments: false, + }, + undefined, + this.#extendedConfigCache, + ), + ); + + const tsConfigFiles = [toPosixPath(tsconfig), ...this.#extendedConfigCache.keys()]; + + let rootNames = originalRootNames; + let fallbackRootNames: string[] | undefined; + if (compilerOptionOverrides?.rootFiles?.length) { + // When explicit rootFiles are provided (e.g., filtered unit test entry points), restrict + // the initial program to only those entry points so TypeScript resolves only their + // transitive imports and skips unrelated or non-included test/application files. + const rootFilesSet = new Set( + compilerOptionOverrides.rootFiles.map((file) => canonicalizePath(toPosixPath(file))), + ); + const fallbackFilesSet = new Set(rootFilesSet); + for (const file of originalRootNames) { + const canonicalFile = canonicalizePath(toPosixPath(file)); + // Ambient declaration files (.d.ts) are not imported directly, so they must always be + // retained from the tsconfig to preserve global type definitions. + if (/\.d\.[cm]?ts$/i.test(file)) { + rootFilesSet.add(canonicalFile); + } + fallbackFilesSet.add(canonicalFile); + } + rootNames = [...rootFilesSet]; + // For application builds (including unit tests), preserve the full set of tsconfig root + // files as a fallback in case the AOT compilation encounters a non-standalone component + // whose declaring NgModule is not directly imported by the entry points. Note that when a + // test tsconfig uses broad globs (e.g., `src/**/*.ts`), this fallback set will also include + // non-included spec files; this is an acceptable trade-off to keep compilation agnostic of + // test-file naming conventions and only occurs for legacy non-standalone components lacking + // explicit NgModule imports. Library builds are excluded because all public NgModules must + // be reachable from the library entry points and should never resolve compilation scope from + // unexported tsconfig files. + if (buildType === 'application' && fallbackFilesSet.size > rootFilesSet.size) { + fallbackRootNames = [...fallbackFilesSet]; + } + } + + const { compilerOptions, warnings } = transformCompilerOptions( + ts, + originalCompilerOptions, + compilerOptionOverrides, + tsconfig, + buildType, + ); + + return { + compilerOptions, + rootNames, + fallbackRootNames, + errors, + warnings, + tsConfigFiles, + }; + } + + protected readonly sourceFiles = new Map(); + + protected invalidateFiles(files: Iterable): void { + for (const file of files) { + const posixFile = toPosixPath(file); + this.sourceFiles.delete(posixFile); + + if (this.#extendedConfigCache.size === 0) { + continue; + } + + if (this.#extendedConfigCache.delete(posixFile)) { + continue; + } + + // Check with lowercased key because TypeScript lowercases the keys + // of the extended config cache on case-insensitive operating systems. + this.#extendedConfigCache.delete(posixFile.toLowerCase()); + } + } + + override async update(files: Set): Promise { + this.invalidateFiles(files); + } + + protected abstract collectDiagnostics( + modes: DiagnosticModes, + ): Iterable | Promise>; + + override async diagnoseFiles( + modes = DiagnosticModes.All, + ): Promise<{ errors?: PartialMessage[]; warnings?: PartialMessage[] }> { + if (modes === DiagnosticModes.None) { + return {}; + } + + const result: { errors?: PartialMessage[]; warnings?: PartialMessage[] } = {}; + + await profileAsync('NG_DIAGNOSTICS_TOTAL', async () => { + const diagnostics = await this.collectDiagnostics(modes); + + for (const diagnostic of diagnostics) { + const message = convertTypeScriptDiagnostic(ts, diagnostic); + if (diagnostic.category === ts.DiagnosticCategory.Error) { + (result.errors ??= []).push(message); + } else { + (result.warnings ??= []).push(message); + } + } + }); + + return result; + } +} diff --git a/packages/angular/build/src/tools/angular/compilation/typescript-printer.ts b/packages/angular/build/src/tools/angular/compilation/typescript-printer.ts new file mode 100644 index 000000000000..04d90ddb01c0 --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/typescript-printer.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview Helper functions and typings for utilizing internal TypeScript printer + * and sourcemap generation APIs. + */ + +import assert from 'node:assert'; +import { dirname } from 'node:path'; +import ts from 'typescript'; + +/** + * Partial interface representing the internal TypeScript `EmitTextWriter` API. + * Used to collect printed text output from the printer. + */ +export interface EmitTextWriter { + write(s: string): void; + rawWrite(s: string): void; + writeLine(force?: boolean): void; + getText(): string; + clear(): void; + isAtStartOfLine(): boolean; + getTextPos(): number; + writeComment(s: string): void; +} + +/** + * Partial interface representing the internal TypeScript `SourceMapGenerator` API. + * Used to construct mappings and serialize a sourcemap. + */ +export interface SourceMapGenerator { + getSources(): string[]; + addSource(fileName: string): number; + setSourceContent(sourceIndex: number, content: string | null): void; + addMapping( + generatedLine: number, + generatedCharacter: number, + sourceIndex: number, + sourceLine: number, + sourceCharacter: number, + nameIndex?: number, + ): void; + toJSON(): object; + toString(): string; +} + +/** + * Extended `ts.Printer` interface containing the internal `writeFile` method + * which supports sourcemap generation. + */ +export interface ExtendedPrinter extends ts.Printer { + writeFile( + sourceFile: ts.SourceFile, + writer: EmitTextWriter, + sourceMapGenerator?: SourceMapGenerator, + ): void; +} + +/** + * Typing structure for internal TypeScript module exports that are not exposed + * in the public `@types/typescript` package. + */ +export interface TypeScriptInternals { + createTextWriter(newLine: string): EmitTextWriter; + createSourceMapGenerator( + host: { + getCurrentDirectory(): string; + getCanonicalFileName(fileName: string): string; + }, + file: string, + sourceRoot: string | undefined, + sourcesDirectoryPath: string, + generatorOptions: ts.CompilerOptions, + ): SourceMapGenerator; +} + +const tsInternals = ts as unknown as TypeScriptInternals; + +/** + * Asserts that the required internal TypeScript APIs are present in the currently + * loaded TypeScript module. + * + * @throws {AssertionError} If any required internal API is missing. + */ +export function assertTypeScriptPrinterInternals(): void { + assert( + typeof tsInternals.createTextWriter === 'function', + 'TypeScript internal "createTextWriter" is missing.', + ); + assert( + typeof tsInternals.createSourceMapGenerator === 'function', + 'TypeScript internal "createSourceMapGenerator" is missing.', + ); +} + +/** + * Result object returned from printing a source file. + */ +export interface PrintResult { + /** The printed source code text. */ + code: string; + + /** The generated sourcemap JSON string, if sourcemap generation was requested. */ + map?: string; +} + +/** + * Prints a TypeScript source file AST to a string, optionally generating a sourcemap + * using internal TypeScript APIs. + * + * @param sourceFile The TypeScript AST node representing the file to print. + * @param printer The printer instance to print the file with. + * @param compilerHost The TypeScript compiler host, used for path canonicalization and context. + * @param compilerOptions The compiler options configured for the build target. + * @returns A result containing the printed code and optional sourcemap text. + */ +export function printSourceFileWithMap( + sourceFile: ts.SourceFile, + printer: ts.Printer, + compilerHost: ts.CompilerHost, + compilerOptions: ts.CompilerOptions, +): PrintResult { + const shouldGenerateMap = compilerOptions.sourceMap || compilerOptions.inlineSourceMap; + if (!shouldGenerateMap) { + return { code: printer.printFile(sourceFile) }; + } + + assertTypeScriptPrinterInternals(); + + const extendedPrinter = printer as ExtendedPrinter; + assert( + typeof extendedPrinter.writeFile === 'function', + 'TypeScript Printer is missing internal "writeFile" method.', + ); + + const writer = tsInternals.createTextWriter(compilerHost.getNewLine()); + + const sourceMapGenerator = tsInternals.createSourceMapGenerator( + { + getCurrentDirectory: () => compilerHost.getCurrentDirectory(), + getCanonicalFileName: (fileName) => compilerHost.getCanonicalFileName(fileName), + }, + sourceFile.fileName, + compilerOptions.sourceRoot, + dirname(sourceFile.fileName), + compilerOptions, + ); + + extendedPrinter.writeFile(sourceFile, writer, sourceMapGenerator); + + const code = writer.getText(); + const map = sourceMapGenerator.toString(); + + return { code, map }; +} diff --git a/packages/angular/build/src/tools/angular/linker/oxc-ast-host.ts b/packages/angular/build/src/tools/angular/linker/oxc-ast-host.ts new file mode 100644 index 000000000000..245443781601 --- /dev/null +++ b/packages/angular/build/src/tools/angular/linker/oxc-ast-host.ts @@ -0,0 +1,337 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { AstHost, Range } from '@angular/compiler-cli/linker'; +import { FatalLinkerError } from '@angular/compiler-cli/linker'; +import type { + ArrayExpression, + ArrowFunctionExpression, + BooleanLiteral, + CallExpression, + Function as FunctionNode, + Node, + NullLiteral, + NumericLiteral, + ObjectExpression, + StringLiteral, + UnaryExpression, +} from '@oxc-project/types'; + +function isNode(node: unknown): node is Node { + return typeof node === 'object' && node !== null && 'type' in node; +} + +function unwrapParentheses(node: unknown): unknown { + while (isNode(node) && node.type === 'ParenthesizedExpression') { + node = node.expression; + } + + return node; +} + +/** + * An implementation of `AstHost` that queries information from `oxc-parser` AST nodes. + */ +export class OxcAstHost implements AstHost { + getSymbolName(node: unknown): string | null { + node = unwrapParentheses(node); + if (!isNode(node)) { + return null; + } + + if (node.type === 'Identifier') { + return node.name; + } else if (node.type === 'MemberExpression') { + if (!node.computed && node.property.type === 'Identifier') { + return node.property.name; + } + } + + return null; + } + + isStringLiteral(node: unknown): node is StringLiteral { + node = unwrapParentheses(node); + + return isNode(node) && node.type === 'Literal' && typeof node.value === 'string'; + } + + parseStringLiteral(str: unknown): string { + str = unwrapParentheses(str); + if (!this.isStringLiteral(str)) { + throw new FatalLinkerError(str as object, 'Unsupported syntax, expected a string literal.'); + } + + return str.value; + } + + isNumericLiteral(node: unknown): node is NumericLiteral { + node = unwrapParentheses(node); + + return isNode(node) && node.type === 'Literal' && typeof node.value === 'number'; + } + + parseNumericLiteral(num: unknown): number { + num = unwrapParentheses(num); + if (!this.isNumericLiteral(num)) { + throw new FatalLinkerError(num as object, 'Unsupported syntax, expected a numeric literal.'); + } + + return num.value; + } + + isBooleanLiteral(node: unknown): node is BooleanLiteral | UnaryExpression { + node = unwrapParentheses(node); + if (!isNode(node)) { + return false; + } + + return ( + (node.type === 'Literal' && typeof node.value === 'boolean') || isMinifiedBooleanLiteral(node) + ); + } + + parseBooleanLiteral(bool: unknown): boolean { + bool = unwrapParentheses(bool); + if (isNode(bool)) { + if (bool.type === 'Literal' && typeof bool.value === 'boolean') { + return bool.value; + } + if (isMinifiedBooleanLiteral(bool)) { + return !bool.argument.value; + } + } + + throw new FatalLinkerError(bool as object, 'Unsupported syntax, expected a boolean literal.'); + } + + isNull(node: unknown): node is NullLiteral { + node = unwrapParentheses(node); + + return isNode(node) && node.type === 'Literal' && node.value === null; + } + + isArrayLiteral(node: unknown): node is ArrayExpression { + node = unwrapParentheses(node); + + return isNode(node) && node.type === 'ArrayExpression'; + } + + parseArrayLiteral(array: unknown): unknown[] { + array = unwrapParentheses(array); + if (!this.isArrayLiteral(array)) { + throw new FatalLinkerError(array as object, 'Unsupported syntax, expected an array literal.'); + } + + const result: unknown[] = []; + + for (const element of array.elements) { + if (element === null) { + throw new FatalLinkerError( + array as object, + 'Unsupported syntax, element in array not to be empty.', + ); + } + const unwrappedElement = unwrapParentheses(element); + if (isNode(unwrappedElement) && unwrappedElement.type === 'SpreadElement') { + throw new FatalLinkerError( + unwrappedElement as object, + 'Unsupported syntax, element in array not to use spread syntax.', + ); + } + result.push(unwrappedElement); + } + + return result; + } + + isObjectLiteral(node: unknown): node is ObjectExpression { + node = unwrapParentheses(node); + + return isNode(node) && node.type === 'ObjectExpression'; + } + + parseObjectLiteral(obj: unknown): Map { + obj = unwrapParentheses(obj); + if (!this.isObjectLiteral(obj)) { + throw new FatalLinkerError(obj as object, 'Unsupported syntax, expected an object literal.'); + } + + const result = new Map(); + + for (const property of obj.properties) { + if (property.type !== 'Property') { + throw new FatalLinkerError( + property as object, + 'Unsupported syntax, expected a property assignment.', + ); + } + + const keyNode = unwrapParentheses(property.key); + if (!isNode(keyNode)) { + throw new FatalLinkerError( + property.key as object, + 'Unsupported syntax, expected a property name.', + ); + } + + let key: string; + if (keyNode.type === 'Identifier') { + key = keyNode.name; + } else if (this.isStringLiteral(keyNode)) { + key = keyNode.value; + } else if (this.isNumericLiteral(keyNode)) { + key = String(keyNode.value); + } else { + throw new FatalLinkerError( + keyNode as object, + 'Unsupported syntax, expected a property name.', + ); + } + + result.set(key, unwrapParentheses(property.value)); + } + + return result; + } + + isFunctionExpression(node: unknown): node is FunctionNode | ArrowFunctionExpression { + node = unwrapParentheses(node); + if (!isNode(node)) { + return false; + } + + return ( + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' + ); + } + + parseReturnValue(fn: unknown): unknown { + fn = unwrapParentheses(fn); + if (!this.isFunctionExpression(fn)) { + throw new FatalLinkerError(fn as object, 'Unsupported syntax, expected a function.'); + } + + const body = fn.body; + if (!body || !isNode(body)) { + throw new FatalLinkerError(fn as object, 'Unsupported syntax, expected a function body.'); + } + + if (body.type !== 'BlockStatement') { + return unwrapParentheses(body); + } + + const statements = body.body; + if (statements.length !== 1) { + throw new FatalLinkerError( + body as object, + 'Unsupported syntax, expected a function body with a single return statement.', + ); + } + + const stmt = statements[0]; + if (stmt.type !== 'ReturnStatement') { + throw new FatalLinkerError( + stmt as object, + 'Unsupported syntax, expected a function body with a single return statement.', + ); + } + + if (!stmt.argument) { + throw new FatalLinkerError( + stmt as object, + 'Unsupported syntax, expected function to return a value.', + ); + } + + return unwrapParentheses(stmt.argument); + } + + parseParameters(fn: unknown): unknown[] { + fn = unwrapParentheses(fn); + if (!this.isFunctionExpression(fn)) { + throw new FatalLinkerError(fn as object, 'Unsupported syntax, expected a function.'); + } + + return fn.params; + } + + isCallExpression(node: unknown): node is CallExpression { + node = unwrapParentheses(node); + + return isNode(node) && node.type === 'CallExpression'; + } + + parseCallee(call: unknown): unknown { + call = unwrapParentheses(call); + if (!this.isCallExpression(call)) { + throw new FatalLinkerError(call as object, 'Unsupported syntax, expected a call expression.'); + } + + return unwrapParentheses(call.callee); + } + + parseArguments(call: unknown): unknown[] { + call = unwrapParentheses(call); + if (!this.isCallExpression(call)) { + throw new FatalLinkerError(call as object, 'Unsupported syntax, expected a call expression.'); + } + + const result: unknown[] = []; + + for (const arg of call.arguments) { + const unwrappedArg = unwrapParentheses(arg); + if (isNode(unwrappedArg) && unwrappedArg.type === 'SpreadElement') { + throw new FatalLinkerError( + unwrappedArg as object, + 'Unsupported syntax, argument not to use spread syntax.', + ); + } + result.push(unwrappedArg); + } + + return result; + } + + getRange(node: unknown): Range { + node = unwrapParentheses(node); + if (!isNode(node) || typeof node.start !== 'number' || typeof node.end !== 'number') { + throw new FatalLinkerError( + node as object, + 'Unable to read range for node - it is missing location information.', + ); + } + + return { + startPos: node.start, + startLine: 0, + startCol: 0, + endPos: node.end, + }; + } +} + +function isMinifiedBooleanLiteral( + node: Node, +): node is UnaryExpression & { argument: NumericLiteral } { + if (node.type !== 'UnaryExpression') { + return false; + } + + const arg = node.argument; + + return ( + node.prefix === true && + node.operator === '!' && + arg.type === 'Literal' && + typeof arg.value === 'number' && + (arg.value === 0 || arg.value === 1) + ); +} diff --git a/packages/angular/build/src/tools/angular/linker/oxc-ast-host_spec.ts b/packages/angular/build/src/tools/angular/linker/oxc-ast-host_spec.ts new file mode 100644 index 000000000000..cbe0d6a25292 --- /dev/null +++ b/packages/angular/build/src/tools/angular/linker/oxc-ast-host_spec.ts @@ -0,0 +1,269 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { FatalLinkerError } from '@angular/compiler-cli/linker'; +import { parseSync } from 'oxc-parser'; +import { OxcAstHost } from './oxc-ast-host'; + +describe('OxcAstHost', () => { + let host: OxcAstHost; + + beforeEach(() => { + host = new OxcAstHost(); + }); + + function parseExpression(code: string): unknown { + const { program } = parseSync('test.js', `const x = ${code};`, { range: true }); + const stmt = program.body[0] as { declarations: Array<{ init: unknown }> }; + + return stmt.declarations[0].init; + } + + function parseStatement(code: string): unknown { + const { program } = parseSync('test.js', code, { range: true }); + + return program.body[0]; + } + + describe('getSymbolName', () => { + it('should return the name of an identifier', () => { + const expr = parseExpression('foo'); + expect(host.getSymbolName(expr)).toBe('foo'); + expect(host.getSymbolName(parseExpression('(foo)'))).toBe('foo'); + }); + + it('should return the property name of a member expression', () => { + const expr = parseExpression('foo.bar'); + expect(host.getSymbolName(expr)).toBe('bar'); + expect(host.getSymbolName(parseExpression('(foo.bar)'))).toBe('bar'); + }); + + it('should return null for non-identifier or computed member expressions', () => { + expect(host.getSymbolName(parseExpression('foo[0]'))).toBeNull(); + expect(host.getSymbolName(parseExpression('42'))).toBeNull(); + expect(host.getSymbolName(null)).toBeNull(); + }); + }); + + describe('string literals', () => { + it('should recognize and parse valid string literals', () => { + const expr = parseExpression('"hello"'); + expect(host.isStringLiteral(expr)).toBe(true); + expect(host.parseStringLiteral(expr)).toBe('hello'); + + const parenthesized = parseExpression('("hello")'); + expect(host.isStringLiteral(parenthesized)).toBe(true); + expect(host.parseStringLiteral(parenthesized)).toBe('hello'); + }); + + it('should throw when parsing non-string literals', () => { + const expr = parseExpression('123'); + expect(host.isStringLiteral(expr)).toBe(false); + expect(() => host.parseStringLiteral(expr)).toThrowError(FatalLinkerError); + }); + }); + + describe('numeric literals', () => { + it('should recognize and parse valid numeric literals', () => { + const expr = parseExpression('123'); + expect(host.isNumericLiteral(expr)).toBe(true); + expect(host.parseNumericLiteral(expr)).toBe(123); + + const parenthesized = parseExpression('(123)'); + expect(host.isNumericLiteral(parenthesized)).toBe(true); + expect(host.parseNumericLiteral(parenthesized)).toBe(123); + }); + + it('should throw when parsing non-numeric literals', () => { + const expr = parseExpression('"123"'); + expect(host.isNumericLiteral(expr)).toBe(false); + expect(() => host.parseNumericLiteral(expr)).toThrowError(FatalLinkerError); + }); + }); + + describe('boolean literals', () => { + it('should recognize and parse true and false literals', () => { + const trueExpr = parseExpression('true'); + const falseExpr = parseExpression('false'); + expect(host.isBooleanLiteral(trueExpr)).toBe(true); + expect(host.parseBooleanLiteral(trueExpr)).toBe(true); + expect(host.isBooleanLiteral(falseExpr)).toBe(true); + expect(host.parseBooleanLiteral(falseExpr)).toBe(false); + + const parenthesizedTrue = parseExpression('(true)'); + expect(host.isBooleanLiteral(parenthesizedTrue)).toBe(true); + expect(host.parseBooleanLiteral(parenthesizedTrue)).toBe(true); + }); + + it('should recognize and parse minified boolean literals (!0 and !1)', () => { + const trueExpr = parseExpression('!0'); + const falseExpr = parseExpression('!1'); + expect(host.isBooleanLiteral(trueExpr)).toBe(true); + expect(host.parseBooleanLiteral(trueExpr)).toBe(true); + expect(host.isBooleanLiteral(falseExpr)).toBe(true); + expect(host.parseBooleanLiteral(falseExpr)).toBe(false); + + const parenthesizedMinified = parseExpression('(!0)'); + expect(host.isBooleanLiteral(parenthesizedMinified)).toBe(true); + expect(host.parseBooleanLiteral(parenthesizedMinified)).toBe(true); + }); + + it('should return false for invalid boolean expressions', () => { + expect(host.isBooleanLiteral(parseExpression('!2'))).toBe(false); + expect(() => host.parseBooleanLiteral(parseExpression('!2'))).toThrowError(FatalLinkerError); + }); + }); + + describe('array literals', () => { + it('should recognize and parse array literals', () => { + const expr = parseExpression('[1, "a", true]'); + expect(host.isArrayLiteral(expr)).toBe(true); + expect(host.parseArrayLiteral(expr).length).toBe(3); + + const parenthesized = parseExpression('([1, "a", true])'); + expect(host.isArrayLiteral(parenthesized)).toBe(true); + expect(host.parseArrayLiteral(parenthesized).length).toBe(3); + }); + + it('should unwrap parenthesized elements in array literals', () => { + const expr = parseExpression('[(1), ("a")]'); + const elements = host.parseArrayLiteral(expr); + expect(elements.length).toBe(2); + expect(host.isNumericLiteral(elements[0])).toBe(true); + expect(host.isStringLiteral(elements[1])).toBe(true); + }); + + it('should throw when array contains empty elements or spread syntax', () => { + expect(() => host.parseArrayLiteral(parseExpression('[1, , 2]'))).toThrowError( + FatalLinkerError, + ); + expect(() => host.parseArrayLiteral(parseExpression('[1, ...a]'))).toThrowError( + FatalLinkerError, + ); + expect(() => host.parseArrayLiteral(parseExpression('[1, ...(a)]'))).toThrowError( + FatalLinkerError, + ); + }); + }); + + describe('object literals', () => { + it('should recognize and parse object literals into a Map', () => { + const expr = parseExpression('{ a: 1, "b": 2, 3: "c" }'); + expect(host.isObjectLiteral(expr)).toBe(true); + + const map = host.parseObjectLiteral(expr); + expect(map.size).toBe(3); + expect(map.has('a')).toBe(true); + expect(map.has('b')).toBe(true); + expect(map.has('3')).toBe(true); + }); + + it('should recognize and parse parenthesized object literals', () => { + const expr = parseExpression('({ a: (1), b: ("c") })'); + expect(host.isObjectLiteral(expr)).toBe(true); + + const map = host.parseObjectLiteral(expr); + expect(map.size).toBe(2); + expect(host.isNumericLiteral(map.get('a'))).toBe(true); + expect(host.isStringLiteral(map.get('b'))).toBe(true); + }); + + it('should throw when object literal contains spread or non-property assignments', () => { + expect(() => host.parseObjectLiteral(parseExpression('{ ...a }'))).toThrowError( + FatalLinkerError, + ); + }); + }); + + describe('functions', () => { + it('should parse return value and parameters from arrow functions', () => { + const expr = parseExpression('(a, b) => 42'); + expect(host.isFunctionExpression(expr)).toBe(true); + expect(host.parseParameters(expr).length).toBe(2); + + const returnValue = host.parseReturnValue(expr); + expect(host.isNumericLiteral(returnValue)).toBe(true); + }); + + it('should parse parenthesized arrow functions and concise bodies returning parenthesized object literals', () => { + const expr = parseExpression('() => ({ a: 1 })'); + expect(host.isFunctionExpression(expr)).toBe(true); + + const returnValue = host.parseReturnValue(expr); + expect(host.isObjectLiteral(returnValue)).toBe(true); + const map = host.parseObjectLiteral(returnValue); + expect(map.has('a')).toBe(true); + + const wrappedArrow = parseExpression('((a) => (42))'); + expect(host.isFunctionExpression(wrappedArrow)).toBe(true); + expect(host.parseParameters(wrappedArrow).length).toBe(1); + expect(host.isNumericLiteral(host.parseReturnValue(wrappedArrow))).toBe(true); + }); + + it('should parse return value from function with block statement containing single return', () => { + const stmt = parseStatement('function foo(a) { return "hello"; }'); + expect(host.isFunctionExpression(stmt)).toBe(true); + + const returnValue = host.parseReturnValue(stmt); + expect(host.isStringLiteral(returnValue)).toBe(true); + }); + + it('should parse return value from function returning parenthesized expression', () => { + const stmt = parseStatement('function foo() { return ({ a: 1 }); }'); + expect(host.isFunctionExpression(stmt)).toBe(true); + + const returnValue = host.parseReturnValue(stmt); + expect(host.isObjectLiteral(returnValue)).toBe(true); + }); + + it('should throw when function body has multiple statements or no return', () => { + expect(() => + host.parseReturnValue(parseStatement('function foo() { const x = 1; return x; }')), + ).toThrowError(FatalLinkerError); + expect(() => host.parseReturnValue(parseStatement('function foo() {}'))).toThrowError( + FatalLinkerError, + ); + }); + }); + + describe('call expressions', () => { + it('should parse callee and arguments', () => { + const expr = parseExpression('foo(1, "a")'); + expect(host.isCallExpression(expr)).toBe(true); + expect(host.getSymbolName(host.parseCallee(expr))).toBe('foo'); + expect(host.parseArguments(expr).length).toBe(2); + + const parenthesized = parseExpression('((foo)((1), ("a")))'); + expect(host.isCallExpression(parenthesized)).toBe(true); + expect(host.getSymbolName(host.parseCallee(parenthesized))).toBe('foo'); + const args = host.parseArguments(parenthesized); + expect(args.length).toBe(2); + expect(host.isNumericLiteral(args[0])).toBe(true); + expect(host.isStringLiteral(args[1])).toBe(true); + }); + + it('should throw when call expression arguments contain spread syntax', () => { + expect(() => host.parseArguments(parseExpression('foo(...args)'))).toThrowError( + FatalLinkerError, + ); + }); + }); + + describe('getRange', () => { + it('should return range object for valid AST nodes', () => { + const expr = parseExpression('foo'); + const range = host.getRange(expr); + expect(range.startPos).toBeGreaterThanOrEqual(0); + expect(range.endPos).toBeGreaterThan(range.startPos); + }); + + it('should throw when node is missing range offsets', () => { + expect(() => host.getRange({})).toThrowError(FatalLinkerError); + }); + }); +}); diff --git a/packages/angular/build/src/tools/angular/linker/oxc-linker.ts b/packages/angular/build/src/tools/angular/linker/oxc-linker.ts new file mode 100644 index 000000000000..8870fe1cc7da --- /dev/null +++ b/packages/angular/build/src/tools/angular/linker/oxc-linker.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Logger } from '@angular/compiler-cli'; +import { type DeclarationScope, FileLinker, LinkerEnvironment } from '@angular/compiler-cli/linker'; +import type { + AbsoluteFsPath, + ReadonlyFileSystem, +} from '@angular/compiler-cli/src/ngtsc/file_system'; +import type { CallExpression } from '@oxc-project/types'; +import { OxcAstHost } from './oxc-ast-host'; +import { StringAstFactory } from './string-ast-factory'; + +/** + * A declaration scope that instructs the Angular compiler to emit constant pools + * inside a local IIFE around each linked declaration rather than hoisting shared + * constants to the module level. + * + * Preferred due to: + * - In-Place String Replacement: Enables fast in-place string replacements in + * `MagicString` without parsing or mutating surrounding ES module statements. + * - Better Tree-Shaking Locality: Component constants are strictly encapsulated + * within the component's `@__PURE__` IIFE closure (`(function() { ... })()`). If a + * bundler tree-shakes an unused component from a library FESM, all of its associated + * constants are automatically eliminated without leaving orphan top-level variables. + * - Negligible Wire Size Impact: LZ77/Brotli compression deduplicates repeated IIFE + * wrappers and array literals over the wire to near-zero marginal cost. + */ +class InlineDeclarationScope implements DeclarationScope { + getConstantScopeRef(): null { + return null; + } +} + +const noopFileSystem: ReadonlyFileSystem = { + exists: () => false, + readFile: () => '', + resolve: (...paths: string[]) => paths.join('/'), + dirname: (path: string) => path.split('/').slice(0, -1).join('/'), + relative: (_from: string, to: string) => to, +} as unknown as ReadonlyFileSystem; + +const defaultLogger: Logger = { + level: 1, // LogLevel.info + debug(...args: string[]) { + if (this.level <= 0) { + // eslint-disable-next-line no-console + console.debug(...args); + } + }, + info(...args: string[]) { + if (this.level <= 1) { + // eslint-disable-next-line no-console + console.info(...args); + } + }, + warn(...args: string[]) { + if (this.level <= 2) { + // eslint-disable-next-line no-console + console.warn(...args); + } + }, + error(...args: string[]) { + if (this.level <= 3) { + // eslint-disable-next-line no-console + console.error(...args); + } + }, +}; + +let SHARED_AST_HOST: OxcAstHost; +let SHARED_DECLARATION_SCOPE: InlineDeclarationScope; + +/** + * Manages Angular partial declaration linking using Oxc AST nodes. + */ +export class OxcLinker { + readonly #fileLinker: FileLinker; + + constructor(filename: string, code: string, jit = false) { + SHARED_AST_HOST ??= new OxcAstHost(); + SHARED_DECLARATION_SCOPE ??= new InlineDeclarationScope(); + + const astFactory = new StringAstFactory(code); + const linkerEnvironment = LinkerEnvironment.create( + noopFileSystem, + defaultLogger, + SHARED_AST_HOST, + astFactory, + { linkerJitMode: jit, sourceMapping: false }, + ); + + this.#fileLinker = new FileLinker(linkerEnvironment, filename as AbsoluteFsPath, code); + } + + /** + * Attempts to link an Angular partial declaration CallExpression. + * + * @param node The CallExpression AST node to check and link. + * @returns The linked code string if the node is a partial declaration, or undefined otherwise. + */ + linkCallExpression(node: CallExpression): string | undefined { + const calleeName = SHARED_AST_HOST.getSymbolName(node.callee); + if (!calleeName || !this.#fileLinker.isPartialDeclaration(calleeName)) { + return undefined; + } + + const args = SHARED_AST_HOST.parseArguments(node); + const linkedCode = this.#fileLinker.linkPartialDeclaration( + calleeName, + args, + SHARED_DECLARATION_SCOPE, + ); + + return linkedCode as string; + } +} diff --git a/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts b/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts new file mode 100644 index 000000000000..467658eb1991 --- /dev/null +++ b/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { transform } from '../../oxc/oxc-transform'; + +describe('oxc-linker', () => { + it('should not modify code that does not need linking', () => { + const input = 'const x = 1;'; + const result = transform('test.js', input, { link: true, advancedOptimizations: false }); + expect(result.code).toBe(input); + expect(result.map).toBeUndefined(); + }); + + it('should link a partial directive declaration', () => { + const input = ` + import * as i0 from "@angular/core"; + export class MyDirective {} + MyDirective.ɵdir = i0.ɵɵngDeclareDirective({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyDirective, + selector: "[my-dir]" + }); + `; + + const result = transform('test.js', input, { link: true, advancedOptimizations: false }); + expect(result.code).toContain('i0.ɵɵdefineDirective'); + expect(result.code).not.toContain('i0.ɵɵngDeclareDirective'); + }); + + it('should link a partial component declaration', () => { + const input = ` + import * as i0 from "@angular/core"; + export class MyComponent {} + MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyComponent, + isStandalone: true, + selector: "my-cmp", + template: "Hello" + }); + `; + + const result = transform('test.js', input, { link: true, advancedOptimizations: false }); + expect(result.code).toContain('i0.ɵɵdefineComponent'); + expect(result.code).not.toContain('i0.ɵɵngDeclareComponent'); + }); + + it('should generate a decoded sourcemap when sourcemap option is enabled', () => { + const input = ` + import * as i0 from "@angular/core"; + export class MyDirective {} + MyDirective.ɵdir = i0.ɵɵngDeclareDirective({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyDirective, + selector: "[my-dir]" + }); + `; + + const result = transform('test.js', input, { + link: true, + advancedOptimizations: false, + sourcemap: true, + }); + expect(result.map).toBeDefined(); + expect(result.map?.version).toBe(3); + expect(result.map?.sources).toContain('test.js'); + }); + + it('should link ɵɵngDeclareClassMetadataAsync with parenthesized resolveMetadata return', () => { + const input = ` + import * as i0 from "@angular/core"; + export class DeferredFixture {} + i0.ɵɵngDeclareClassMetadataAsync({ + minVersion: "18.0.0", + version: "22.1.7", + ngImport: i0, + type: DeferredFixture, + resolveDeferredDeps: () => [], + resolveMetadata: () => ({ + decorators: [], + ctorParameters: null, + propDecorators: null, + }), + }); + `; + + const result = transform('test.js', input, { link: true, advancedOptimizations: false }); + expect(result.code).toContain('ɵsetClassMetadataAsync'); + expect(result.code).not.toContain('ɵɵngDeclareClassMetadataAsync'); + }); +}); diff --git a/packages/angular/build/src/tools/angular/linker/string-ast-factory.ts b/packages/angular/build/src/tools/angular/linker/string-ast-factory.ts new file mode 100644 index 000000000000..a703856c39bd --- /dev/null +++ b/packages/angular/build/src/tools/angular/linker/string-ast-factory.ts @@ -0,0 +1,335 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { + AstFactory, + BinaryOperator, + LeadingComment, + ObjectLiteralProperty, + SourceMapRange, + TemplateLiteral, + UnaryOperator, + VariableDeclarationType, +} from '@angular/compiler-cli/src/ngtsc/translator'; +import type { + BuiltInType, + Parameter, +} from '@angular/compiler-cli/src/ngtsc/translator/src/api/ast_factory'; + +const OBJECT_LITERAL_PREFIX = '\0obj_'; + +/** + * An implementation of `AstFactory` that generates JavaScript code strings directly. + */ +export class StringAstFactory implements AstFactory { + constructor(private readonly sourceCode: string = '') {} + + private render(expr: unknown): string { + let rendered: string; + if (typeof expr === 'string') { + rendered = expr; + } else if ( + typeof expr === 'object' && + expr !== null && + typeof (expr as { start?: number; end?: number }).start === 'number' && + typeof (expr as { end?: number }).end === 'number' + ) { + const { start, end } = expr as { start: number; end: number }; + + rendered = this.sourceCode.slice(start, end); + } else { + rendered = String(expr); + } + + if (rendered.startsWith(OBJECT_LITERAL_PREFIX)) { + return rendered.slice(OBJECT_LITERAL_PREFIX.length); + } + + return rendered; + } + + /** + * Wraps an expression in parentheses if it has syntax or operator precedence + * traps when used as the receiver/callee of property access, element access, + * call chains, `new` expressions, or tagged templates. + * + * For example, arrow functions (`(() => {}).foo`), numbers (`(5).foo`), object + * literals (`({ a: 1 }).foo`), and unary operators (`(void 0)()`) require + * parenthesization when used as receivers. + */ + private wrapReceiver(receiver: unknown): string { + let rendered = this.render(receiver); + if ( + rendered.startsWith('function') || + rendered.startsWith('{') || + rendered.includes('=>') || + /^(?:typeof|void|delete|new|await|throw|return)\b/.test(rendered) || + /^[!~+-]/.test(rendered) || + /^\d/.test(rendered) + ) { + rendered = `(${rendered})`; + } + + return rendered; + } + + /** + * Wraps an expression in parentheses if it is an un-parenthesized complex + * operand (such as an un-parenthesized binary, logical, or assignment expression) + * to prevent operator precedence inversion when embedded inside an outer expression. + * + * Atomic expressions (identifiers, member/element access chains, and simple + * literals matching `/^[a-zA-Z0-9_$.[\]"'`]+$/`) and already-parenthesized + * expressions are returned as-is. + */ + private wrapOperand(expr: unknown): string { + const rendered = this.render(expr); + if ( + (rendered.startsWith('(') && rendered.endsWith(')')) || + /^[a-zA-Z0-9_$.[\]"'`]+$/.test(rendered) + ) { + return rendered; + } + + return `(${rendered})`; + } + + /** + * Attaching statement-level comments is a no-op in `StringAstFactory`. + * + * Why this is safe: + * - Load-bearing tree-shaking annotations (`@__PURE__` markers) are explicitly prepended by + * `createCallExpression` and `createCallChain` whenever `pure: true`. + * - In `@angular/compiler`, `LeadingComment` is only used for `@ts-ignore` (suppressing + * TypeScript checker errors in generated `.ts` code) and `webpackChunkName` (in dynamic + * imports), neither of which occur during `.js`/`.mjs` library partial linking. + */ + attachComments(_statement: string, _leadingComments: LeadingComment[]): void {} + + createArrayLiteral(elements: unknown[]): string { + return `[${elements.map((e) => this.render(e)).join(', ')}]`; + } + + createAssignment(target: unknown, operator: BinaryOperator, value: unknown): string { + return `(${this.render(target)} ${operator} ${this.render(value)})`; + } + + createBinaryExpression( + leftOperand: unknown, + operator: BinaryOperator, + rightOperand: unknown, + ): string { + return `(${this.wrapOperand(leftOperand)} ${operator} ${this.wrapOperand(rightOperand)})`; + } + + createBlock(body: string[]): string { + return `{\n${body.join('\n')}\n}`; + } + + createCallExpression(callee: unknown, args: unknown[], pure: boolean): string { + const annotation = pure ? '/*@__PURE__*/ ' : ''; + + return `${annotation}${this.wrapReceiver(callee)}(${args.map((a) => this.render(a)).join(', ')})`; + } + + createCallChain(callee: unknown, args: unknown[], pure: boolean, isOptional: boolean): string { + const annotation = pure ? '/*@__PURE__*/ ' : ''; + const operator = isOptional ? '?.' : ''; + + return `${annotation}${this.wrapReceiver(callee)}${operator}(${args.map((a) => this.render(a)).join(', ')})`; + } + + createConditional(condition: unknown, thenExpression: unknown, elseExpression: unknown): string { + return `(${this.render(condition)} ? ${this.render(thenExpression)} : ${this.render(elseExpression)})`; + } + + createElementAccess(expression: unknown, element: unknown): string { + return `${this.wrapReceiver(expression)}[${this.render(element)}]`; + } + + createElementAccessChain(expression: unknown, element: unknown, isOptional: boolean): string { + const operator = isOptional ? '?.' : ''; + + return `${this.wrapReceiver(expression)}${operator}[${this.render(element)}]`; + } + + createExpressionStatement(expression: unknown): string { + return `${this.render(expression)};`; + } + + createFunctionDeclaration( + functionName: string, + parameters: Parameter[], + body: string, + ): string { + const params = parameters.map((p) => p.name).join(', '); + + return `function ${functionName}(${params}) ${body}`; + } + + createFunctionExpression( + functionName: string | null, + parameters: Parameter[], + body: string, + ): string { + const name = functionName ? ` ${functionName}` : ''; + const params = parameters.map((p) => p.name).join(', '); + + return `function${name}(${params}) ${body}`; + } + + createArrowFunctionExpression(parameters: Parameter[], body: unknown): string { + const params = parameters.map((p) => p.name).join(', '); + const isObjectLiteral = typeof body === 'string' && body.startsWith(OBJECT_LITERAL_PREFIX); + const renderedBody = this.render(body); + const formattedBody = isObjectLiteral ? `(${renderedBody})` : renderedBody; + + return `(${params}) => ${formattedBody}`; + } + + createDynamicImport(url: unknown): string { + return `import(${this.render(url)})`; + } + + createIdentifier(name: string): string { + return name; + } + + createIfStatement( + condition: unknown, + thenStatement: string, + elseStatement: string | null, + ): string { + const elseClause = elseStatement ? ` else ${elseStatement}` : ''; + + return `if (${this.render(condition)}) ${thenStatement}${elseClause}`; + } + + createLiteral(value: string | number | boolean | null | undefined): string { + return typeof value === 'string' ? JSON.stringify(value) : String(value); + } + + createNewExpression(expression: unknown, args: unknown[]): string { + return `new ${this.wrapReceiver(expression)}(${args.map((a) => this.render(a)).join(', ')})`; + } + + createObjectLiteral(properties: ObjectLiteralProperty[]): string { + const props = properties.map((p) => { + if (p.kind === 'spread') { + return `...${this.render(p.expression)}`; + } + + const key = p.quoted ? JSON.stringify(p.propertyName) : p.propertyName; + + return `${key}: ${this.render(p.value)}`; + }); + + return `${OBJECT_LITERAL_PREFIX}{\n${props.join(',\n')}\n}`; + } + + createParenthesizedExpression(expression: unknown): string { + return `(${this.render(expression)})`; + } + + createPropertyAccess(expression: unknown, propertyName: string): string { + return `${this.wrapReceiver(expression)}.${propertyName}`; + } + + createPropertyAccessChain( + expression: unknown, + propertyName: string, + isOptional: boolean, + ): string { + const operator = isOptional ? '?.' : '.'; + + return `${this.wrapReceiver(expression)}${operator}${propertyName}`; + } + + createReturnStatement(expression: unknown | null): string { + return `return${expression !== null ? ` ${this.render(expression)}` : ''};`; + } + + createTaggedTemplate(tag: unknown, template: TemplateLiteral): string { + return `${this.wrapReceiver(tag)}${this.createTemplateLiteral(template)}`; + } + + createTemplateLiteral(template: TemplateLiteral): string { + let result = '`'; + for (let i = 0; i < template.elements.length; i++) { + result += template.elements[i].raw; + if (i < template.expressions.length) { + result += `\${${this.render(template.expressions[i])}}`; + } + } + result += '`'; + + return result; + } + + createThrowStatement(expression: unknown): string { + return `throw ${this.render(expression)};`; + } + + createTypeOfExpression(expression: unknown): string { + return `typeof ${this.wrapOperand(expression)}`; + } + + createVoidExpression(expression: unknown): string { + return `void ${this.wrapOperand(expression)}`; + } + + createUnaryExpression(operator: UnaryOperator, operand: unknown): string { + return `${operator}${this.wrapOperand(operand)}`; + } + + createVariableDeclaration( + variableName: string, + initializer: unknown | null, + variableType: VariableDeclarationType, + _type: string | null = null, + ): string { + const init = initializer !== null ? ` = ${this.render(initializer)}` : ''; + + return `${variableType} ${variableName}${init};`; + } + + createRegularExpressionLiteral(body: string, flags: string | null): string { + return `/${body}/${flags ?? ''}`; + } + + createSpreadElement(expression: unknown): string { + return `...${this.render(expression)}`; + } + + createBuiltInType(_type: BuiltInType): string { + return ''; + } + + createExpressionType(_expression: unknown, _typeParams: string[] | null): string { + return ''; + } + + createArrayType(_elementType: string): string { + return ''; + } + + createMapType(_valueType: string): string { + return ''; + } + + transplantType(_type: string): string { + return ''; + } + + setSourceMapRange( + node: T, + _sourceMapRange: SourceMapRange | null, + ): T { + return node; + } +} diff --git a/packages/angular/build/src/tools/angular/linker/string-ast-factory_spec.ts b/packages/angular/build/src/tools/angular/linker/string-ast-factory_spec.ts new file mode 100644 index 000000000000..eb62a358dfcd --- /dev/null +++ b/packages/angular/build/src/tools/angular/linker/string-ast-factory_spec.ts @@ -0,0 +1,210 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { StringAstFactory } from './string-ast-factory'; + +describe('StringAstFactory', () => { + let factory: StringAstFactory; + + beforeEach(() => { + factory = new StringAstFactory('const foo = bar;'); + }); + + describe('source slicing (render)', () => { + it('should slice original source code when given an AST object with start and end offsets', () => { + const astNode = { start: 6, end: 9 }; + expect(factory.createExpressionStatement(astNode)).toBe('foo;'); + }); + }); + + describe('literals and identifiers', () => { + it('should correctly format string literals', () => { + expect(factory.createLiteral('hello')).toBe('"hello"'); + }); + + it('should correctly format numeric literals including NaN and Infinity', () => { + expect(factory.createLiteral(123)).toBe('123'); + expect(factory.createLiteral(NaN)).toBe('NaN'); + expect(factory.createLiteral(Infinity)).toBe('Infinity'); + }); + + it('should correctly format boolean literals', () => { + expect(factory.createLiteral(true)).toBe('true'); + expect(factory.createLiteral(false)).toBe('false'); + }); + + it('should correctly format null and undefined literals', () => { + expect(factory.createLiteral(null)).toBe('null'); + expect(factory.createLiteral(undefined)).toBe('undefined'); + }); + + it('should correctly create identifiers', () => { + expect(factory.createIdentifier('myVar')).toBe('myVar'); + }); + + it('should correctly format regular expression literals', () => { + expect(factory.createRegularExpressionLiteral('abc+', 'g')).toBe('/abc+/g'); + expect(factory.createRegularExpressionLiteral('abc+', null)).toBe('/abc+/'); + }); + }); + + describe('array and object literals', () => { + it('should correctly format array literals', () => { + expect(factory.createArrayLiteral(['1', '2', '3'])).toBe('[1, 2, 3]'); + }); + + it('should correctly format object literals with quoted and unquoted keys', () => { + const props = [ + { kind: 'property' as const, propertyName: 'foo', value: '1', quoted: false }, + { kind: 'property' as const, propertyName: 'foo-bar', value: '2', quoted: true }, + ]; + expect(factory.createObjectLiteral(props)).toContain('{\nfoo: 1,\n"foo-bar": 2\n}'); + }); + + it('should correctly format object literals with spread properties', () => { + const props = [ + { kind: 'property' as const, propertyName: 'foo', value: '1', quoted: false }, + { kind: 'spread' as const, expression: 'bar' }, + ]; + expect(factory.createObjectLiteral(props)).toContain('{\nfoo: 1,\n...bar\n}'); + }); + }); + + describe('property and element access', () => { + it('should correctly format property access and property access chains', () => { + expect(factory.createPropertyAccess('foo', 'bar')).toBe('foo.bar'); + expect(factory.createPropertyAccessChain('foo', 'bar', true)).toBe('foo?.bar'); + expect(factory.createPropertyAccessChain('foo', 'bar', false)).toBe('foo.bar'); + }); + + it('should correctly format element access and element access chains', () => { + expect(factory.createElementAccess('foo', '"bar"')).toBe('foo["bar"]'); + expect(factory.createElementAccessChain('foo', '"bar"', true)).toBe('foo?.["bar"]'); + expect(factory.createElementAccessChain('foo', '"bar"', false)).toBe('foo["bar"]'); + }); + + it('should parenthesize precedence-sensitive receivers in property and element access', () => { + expect(factory.createPropertyAccess('() => {}', 'bar')).toBe('(() => {}).bar'); + expect(factory.createPropertyAccess('5', 'bar')).toBe('(5).bar'); + expect(factory.createPropertyAccess('typeof x', 'bar')).toBe('(typeof x).bar'); + expect(factory.createElementAccess('() => {}', '"bar"')).toBe('(() => {})["bar"]'); + }); + }); + + describe('call expressions and chains', () => { + it('should format standard and pure call expressions', () => { + expect(factory.createCallExpression('foo', ['1', '2'], false)).toBe('foo(1, 2)'); + expect(factory.createCallExpression('foo', ['1', '2'], true)).toBe('/*@__PURE__*/ foo(1, 2)'); + }); + + it('should wrap arrow functions and function expressions in parentheses when used as callees', () => { + expect(factory.createCallExpression('() => {}', [], false)).toBe('(() => {})()'); + expect(factory.createCallExpression('function() {}', [], false)).toBe('(function() {})()'); + expect(factory.createCallChain('() => {}', [], false, true)).toBe('(() => {})?.()'); + expect(factory.createNewExpression('() => Foo', [])).toBe('new (() => Foo)()'); + }); + + it('should format optional call chains', () => { + expect(factory.createCallChain('foo', ['1'], false, true)).toBe('foo?.(1)'); + expect(factory.createCallChain('foo', ['1'], false, false)).toBe('foo(1)'); + }); + + it('should format new expressions', () => { + expect(factory.createNewExpression('Foo', ['1', '2'])).toBe('new Foo(1, 2)'); + }); + }); + + describe('operators and expressions', () => { + it('should format binary and assignment expressions with parentheses', () => { + expect(factory.createBinaryExpression('a', '+', 'b')).toBe('(a + b)'); + expect(factory.createBinaryExpression('a + b', '*', 'c + d')).toBe('((a + b) * (c + d))'); + expect(factory.createAssignment('a', '=', 'b')).toBe('(a = b)'); + }); + + it('should format conditional expressions with parentheses', () => { + expect(factory.createConditional('cond', 'trueVal', 'falseVal')).toBe( + '(cond ? trueVal : falseVal)', + ); + }); + + it('should format unary expressions', () => { + expect(factory.createUnaryExpression('!', 'foo')).toBe('!foo'); + expect(factory.createTypeOfExpression('foo')).toBe('typeof foo'); + expect(factory.createVoidExpression('foo')).toBe('void foo'); + expect(factory.createUnaryExpression('!', 'a + b')).toBe('!(a + b)'); + }); + }); + + describe('functions and statements', () => { + it('should format function declarations and expressions', () => { + const params = [ + { name: 'a', type: null }, + { name: 'b', type: null }, + ]; + expect(factory.createFunctionDeclaration('foo', params, '{ return a + b; }')).toBe( + 'function foo(a, b) { return a + b; }', + ); + expect(factory.createFunctionExpression('foo', params, '{ return a + b; }')).toBe( + 'function foo(a, b) { return a + b; }', + ); + expect(factory.createFunctionExpression(null, params, '{ return a + b; }')).toBe( + 'function(a, b) { return a + b; }', + ); + }); + + it('should format arrow functions', () => { + const params = [ + { name: 'a', type: null }, + { name: 'b', type: null }, + ]; + expect(factory.createArrowFunctionExpression(params, 'a + b')).toBe('(a, b) => a + b'); + }); + + it('should parenthesize object literal expression bodies in arrow functions even when containing semicolons', () => { + const params = [{ name: 'a', type: null }]; + const obj = factory.createObjectLiteral([ + { + kind: 'property', + propertyName: 'factory', + value: '() => { return new Service(); }', + quoted: false, + }, + ]); + expect(factory.createArrowFunctionExpression(params, obj)).toBe( + '(a) => ({\nfactory: () => { return new Service(); }\n})', + ); + }); + + it('should format statements', () => { + expect(factory.createBlock(['foo();', 'bar();'])).toBe('{\nfoo();\nbar();\n}'); + expect(factory.createIfStatement('cond', 'foo();', 'bar();')).toBe( + 'if (cond) foo(); else bar();', + ); + expect(factory.createIfStatement('cond', 'foo();', null)).toBe('if (cond) foo();'); + expect(factory.createReturnStatement('val')).toBe('return val;'); + expect(factory.createReturnStatement(null)).toBe('return;'); + expect(factory.createThrowStatement('err')).toBe('throw err;'); + expect(factory.createVariableDeclaration('x', '1', 'const')).toBe('const x = 1;'); + expect(factory.createVariableDeclaration('y', null, 'let')).toBe('let y;'); + }); + }); + + describe('template literals', () => { + it('should format template literals and tagged templates', () => { + const template = { + elements: [ + { raw: 'Hello ', cooked: 'Hello ', range: null }, + { raw: '!', cooked: '!', range: null }, + ], + expressions: ['name'], + }; + expect(factory.createTemplateLiteral(template)).toBe('`Hello ${name}!`'); + expect(factory.createTaggedTemplate('tag', template)).toBe('tag`Hello ${name}!`'); + }); + }); +}); diff --git a/packages/angular/build/src/tools/angular/transformers/jit-bootstrap-transformer.ts b/packages/angular/build/src/tools/angular/transformers/jit-bootstrap-transformer.ts new file mode 100644 index 000000000000..4dbaf010f3a2 --- /dev/null +++ b/packages/angular/build/src/tools/angular/transformers/jit-bootstrap-transformer.ts @@ -0,0 +1,335 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import ts from 'typescript'; + +/** + * The name of the Angular platform that should be replaced within + * bootstrap call expressions to support AOT. + */ +const PLATFORM_BROWSER_DYNAMIC_NAME = 'platformBrowserDynamic'; + +export function replaceBootstrap( + getTypeChecker: () => ts.TypeChecker, +): ts.TransformerFactory { + return (context: ts.TransformationContext) => { + let bootstrapImport: ts.ImportDeclaration | undefined; + let bootstrapNamespace: ts.Identifier | undefined; + const replacedNodes: ts.Node[] = []; + const nodeFactory = context.factory; + + const visitNode: ts.Visitor = (node: ts.Node) => { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + const target = node.expression; + if (target.text === PLATFORM_BROWSER_DYNAMIC_NAME) { + if (!bootstrapNamespace) { + bootstrapNamespace = nodeFactory.createUniqueName('__NgCli_bootstrap_'); + bootstrapImport = nodeFactory.createImportDeclaration( + undefined, + nodeFactory.createImportClause( + undefined, + undefined, + nodeFactory.createNamespaceImport(bootstrapNamespace), + ), + nodeFactory.createStringLiteral('@angular/platform-browser'), + ); + } + replacedNodes.push(target); + + return nodeFactory.updateCallExpression( + node, + nodeFactory.createPropertyAccessExpression(bootstrapNamespace, 'platformBrowser'), + node.typeArguments, + node.arguments, + ); + } + } + + return ts.visitEachChild(node, visitNode, context); + }; + + return (sourceFile: ts.SourceFile) => { + if (!sourceFile.text.includes(PLATFORM_BROWSER_DYNAMIC_NAME)) { + return sourceFile; + } + + let updatedSourceFile = ts.visitEachChild(sourceFile, visitNode, context); + + if (bootstrapImport) { + // Remove any unused platform browser dynamic imports + const removals = elideImports( + updatedSourceFile, + replacedNodes, + getTypeChecker, + context.getCompilerOptions(), + ); + if (removals.size > 0) { + updatedSourceFile = ts.visitEachChild( + updatedSourceFile, + function visitForRemoval(node): ts.Node | undefined { + if (removals.has(node)) { + return undefined; + } + + return ts.isImportDeclaration(node) || + ts.isImportClause(node) || + ts.isNamedImports(node) + ? ts.visitEachChild(node, visitForRemoval, context) + : node; + }, + context, + ); + } + + // Add new platform browser import + return nodeFactory.updateSourceFile( + updatedSourceFile, + ts.setTextRange( + nodeFactory.createNodeArray([bootstrapImport, ...updatedSourceFile.statements]), + sourceFile.statements, + ), + ); + } else { + return updatedSourceFile; + } + }; + }; +} + +// Remove imports for which all identifiers have been removed. +// Needs type checker, and works even if it's not the first transformer. +// Works by removing imports for symbols whose identifiers have all been removed. +// Doesn't use the `symbol.declarations` because that previous transforms might have removed nodes +// but the type checker doesn't know. +// See https://github.com/Microsoft/TypeScript/issues/17552 for more information. +export function elideImports( + sourceFile: ts.SourceFile, + removedNodes: ts.Node[], + getTypeChecker: () => ts.TypeChecker, + compilerOptions: ts.CompilerOptions, +): Set { + const importNodeRemovals = new Set(); + + if (removedNodes.length === 0) { + return importNodeRemovals; + } + + const typeChecker = getTypeChecker(); + + // Collect all imports and used identifiers + const usedSymbols = new Set(); + const imports: ts.ImportDeclaration[] = []; + + ts.forEachChild(sourceFile, function visit(node) { + // Skip removed nodes. + if (removedNodes.includes(node)) { + return; + } + + // Consider types for 'implements' as unused. + // A HeritageClause token can also be an 'AbstractKeyword' + // which in that case we should not elide the import. + if (ts.isHeritageClause(node) && node.token === ts.SyntaxKind.ImplementsKeyword) { + return; + } + + // Record import and skip + if (ts.isImportDeclaration(node)) { + if (!node.importClause?.isTypeOnly) { + imports.push(node); + } + + return; + } + + // Type nodes do not need to be emitted when `emitDecoratorMetadata` is disabled. + // `ExpressionWithTypeArguments` is excluded as it also represents runtime values such as + // `class ... extends Base` heritage clauses and instantiation expressions (`Map`). + if ( + ts.isTypeNode(node) && + !ts.isExpressionWithTypeArguments(node) && + !compilerOptions.emitDecoratorMetadata + ) { + return; + } + + // Pure type declarations, type queries, ambient statements, external re-exports, + // and type-only exports are erased or do not reference local imports at runtime. + if ( + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isTypeParameterDeclaration(node) || + ts.isTypeQueryNode(node) || + ts.isImportTypeNode(node) || + ts.isIndexSignatureDeclaration(node) || + (ts.isStatement(node) && + ts.canHaveModifiers(node) && + ts.getModifiers(node)?.some((m) => m.kind === ts.SyntaxKind.DeclareKeyword)) || + (ts.isExportDeclaration(node) && (node.isTypeOnly || node.moduleSpecifier !== undefined)) || + (ts.isExportSpecifier(node) && node.isTypeOnly) + ) { + return; + } + + let symbol: ts.Symbol | undefined; + switch (node.kind) { + case ts.SyntaxKind.Identifier: + if (node.parent && ts.isShorthandPropertyAssignment(node.parent)) { + const shorthandSymbol = typeChecker.getShorthandAssignmentValueSymbol(node.parent); + if (shorthandSymbol) { + symbol = shorthandSymbol; + } + } else { + symbol = typeChecker.getSymbolAtLocation(node); + } + break; + case ts.SyntaxKind.ExportSpecifier: + symbol = typeChecker.getExportSpecifierLocalTargetSymbol(node as ts.ExportSpecifier); + break; + case ts.SyntaxKind.ShorthandPropertyAssignment: + symbol = typeChecker.getShorthandAssignmentValueSymbol(node); + break; + } + + if (symbol) { + usedSymbols.add(symbol); + } + + ts.forEachChild(node, visit); + }); + + if (imports.length === 0) { + return importNodeRemovals; + } + + /** + * Determines how an imported binding should be handled: + * - `keep`: the binding is still referenced. + * - `value`: the binding is a runtime value that is no longer referenced. + * - `type`: the binding does not exist at runtime and is always elided by TypeScript. + */ + const getRemovalKind = (node: ts.Identifier): 'keep' | 'value' | 'type' => { + // Do not remove JSX factory imports + if (node.text === compilerOptions.jsxFactory) { + return 'keep'; + } + + const symbol = typeChecker.getSymbolAtLocation(node); + if (!symbol) { + return 'keep'; + } + + // Note: with `verbatimModuleSyntax`, TypeScript reports an error for imports of non-value aliases + // that do not use a `type` modifier (TS1484/TS1485), so no special handling is needed for it. + if ((symbol.flags & ts.SymbolFlags.Alias) !== 0 && !isValueAlias(symbol, typeChecker)) { + return 'type'; + } + + return usedSymbols.has(symbol) ? 'keep' : 'value'; + }; + + for (const node of imports) { + if (!node.importClause) { + // "import 'abc';" + continue; + } + + const namedBindings = node.importClause.namedBindings; + + if (namedBindings && ts.isNamespaceImport(namedBindings)) { + // "import * as XYZ from 'abc';" + if (getRemovalKind(namedBindings.name) !== 'keep') { + importNodeRemovals.add(node); + } + } else { + const specifierNodeRemovals = []; + let clausesCount = 0; + // TypeScript skips its own import elision for declarations replaced by a transformer. + // A declaration is therefore only partially rewritten when a runtime value import must be + // removed, or when it was already replaced by a previous transformer. Otherwise, it is left + // untouched so that TypeScript continues to elide any type-only specifiers itself. + let requiresRewrite = !ts.isParseTreeNode(node); + + // "import { XYZ, ... } from 'abc';" + if (namedBindings && ts.isNamedImports(namedBindings)) { + let removedClausesCount = 0; + clausesCount += namedBindings.elements.length; + + for (const specifier of namedBindings.elements) { + const removalKind = specifier.isTypeOnly ? 'type' : getRemovalKind(specifier.name); + if (removalKind !== 'keep') { + requiresRewrite ||= removalKind === 'value'; + removedClausesCount++; + // in case we don't have any more namedImports we should remove the parent ie the {} + const nodeToRemove = + clausesCount === removedClausesCount ? specifier.parent : specifier; + + specifierNodeRemovals.push(nodeToRemove); + } + } + } + + // "import XYZ from 'abc';" + if (node.importClause.name) { + clausesCount++; + + const removalKind = node.importClause.isTypeOnly + ? 'type' + : getRemovalKind(node.importClause.name); + if (removalKind !== 'keep') { + requiresRewrite ||= removalKind === 'value'; + specifierNodeRemovals.push(node.importClause.name); + } + } + + if (specifierNodeRemovals.length === clausesCount) { + importNodeRemovals.add(node); + } else if (requiresRewrite) { + for (const specifierNodeRemoval of specifierNodeRemovals) { + importNodeRemovals.add(specifierNodeRemoval); + } + } + } + } + + return importNodeRemovals; +} + +/** + * Determines whether an import alias resolves to a runtime value. Aliases that only resolve to + * types, or that pass through a type-only import or export declaration anywhere along the alias + * chain, are always elided by TypeScript. + */ +function isValueAlias(symbol: ts.Symbol, typeChecker: ts.TypeChecker): boolean { + const seen = new Set(); + let current = symbol; + + while ((current.flags & ts.SymbolFlags.Alias) !== 0) { + if (seen.has(current)) { + // Circular alias (a TypeScript error) — assume runtime value so we don't over-elide. + return true; + } + seen.add(current); + + if ( + current.declarations?.length && + current.declarations.every(ts.isTypeOnlyImportOrExportDeclaration) + ) { + return false; + } + + const next = typeChecker.getImmediateAliasedSymbol(current); + if (!next) { + // Unresolved alias (e.g., missing module) — assume runtime value so we don't over-elide. + return true; + } + current = next; + } + + return (current.flags & ts.SymbolFlags.Value) !== 0; +} diff --git a/packages/angular/build/src/tools/angular/transformers/jit-bootstrap-transformer_spec.ts b/packages/angular/build/src/tools/angular/transformers/jit-bootstrap-transformer_spec.ts new file mode 100644 index 000000000000..cb81319e0db3 --- /dev/null +++ b/packages/angular/build/src/tools/angular/transformers/jit-bootstrap-transformer_spec.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import ts from 'typescript'; +import { replaceBootstrap } from './jit-bootstrap-transformer'; + +describe('replaceBootstrap', () => { + function transformCode( + files: Record, + entryFile = '/project/src/main.ts', + ): string { + const compilerOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noLib: true, + }; + + const host: ts.CompilerHost = { + getSourceFile: (fileName, languageVersion) => + files[fileName] !== undefined + ? ts.createSourceFile(fileName, files[fileName], languageVersion, true) + : undefined, + getDefaultLibFileName: () => '/lib.d.ts', + writeFile: () => undefined, + getCurrentDirectory: () => '/project', + getCanonicalFileName: (fileName) => fileName, + useCaseSensitiveFileNames: () => true, + getNewLine: () => '\n', + fileExists: (fileName) => files[fileName] !== undefined, + readFile: (fileName) => files[fileName], + }; + + const program = ts.createProgram([entryFile], compilerOptions, host); + let output = ''; + const sourceFile = program.getSourceFile(entryFile); + expect(sourceFile).toBeDefined(); + + program.emit( + sourceFile, + (_fileName, text) => { + output = text; + }, + undefined, + false, + { before: [replaceBootstrap(() => program.getTypeChecker())] }, + ); + + return output; + } + + const platformBrowserDynamicStub = ` + export declare function platformBrowserDynamic(extraProviders?: unknown[]): { + bootstrapModule(moduleType: unknown): Promise; + }; + export const VERSION = '0.0.0'; + export interface PlatformHelper {} + `; + + it('replaces platformBrowserDynamic and removes its import when no other specifiers remain', () => { + const output = transformCode({ + '/project/node_modules/@angular/platform-browser-dynamic/index.d.ts': + platformBrowserDynamicStub, + '/project/src/app.module.ts': 'export class AppModule {}', + '/project/src/main.ts': ` + import { platformBrowserDynamic, PlatformHelper } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app.module'; + + export { PlatformHelper }; + platformBrowserDynamic().bootstrapModule(AppModule); + `, + }); + + expect(output).toContain(`import * as __NgCli_bootstrap_1 from "@angular/platform-browser";`); + expect(output).toContain(`__NgCli_bootstrap_1.platformBrowser().bootstrapModule(AppModule);`); + expect(output).not.toContain('@angular/platform-browser-dynamic'); + }); + + it('removes only platformBrowserDynamic specifier when another runtime export from the same module is used', () => { + const output = transformCode({ + '/project/node_modules/@angular/platform-browser-dynamic/index.d.ts': + platformBrowserDynamicStub, + '/project/src/app.module.ts': 'export class AppModule {}', + '/project/src/main.ts': ` + import { platformBrowserDynamic, VERSION, PlatformHelper } from '@angular/platform-browser-dynamic'; + import { AppModule } from './app.module'; + + export type { PlatformHelper }; + export const version = VERSION; + platformBrowserDynamic().bootstrapModule(AppModule); + `, + }); + + expect(output).toContain(`import { VERSION } from '@angular/platform-browser-dynamic';`); + expect(output).not.toContain('PlatformHelper'); + expect(output).toContain(`__NgCli_bootstrap_1.platformBrowser().bootstrapModule(AppModule);`); + }); +}); diff --git a/packages/angular/build/src/tools/angular/transformers/jit-resource-transformer.ts b/packages/angular/build/src/tools/angular/transformers/jit-resource-transformer.ts new file mode 100644 index 000000000000..2a064ba63cb5 --- /dev/null +++ b/packages/angular/build/src/tools/angular/transformers/jit-resource-transformer.ts @@ -0,0 +1,308 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import ts from 'typescript'; +import { generateJitFileUri, generateJitInlineUri } from '../uri'; + +/** + * Creates a TypeScript Transformer to transform Angular Component resource references into + * static import statements. This transformer is used in Angular's JIT compilation mode to + * support processing of component resources. When in AOT mode, the Angular AOT compiler handles + * this processing and this transformer is not used. + * @param getTypeChecker A function that returns a TypeScript TypeChecker instance for the program. + * @returns A TypeScript transformer factory. + */ +export function createJitResourceTransformer( + getTypeChecker: () => ts.TypeChecker, +): ts.TransformerFactory { + return (context: ts.TransformationContext) => { + const typeChecker = getTypeChecker(); + const nodeFactory = context.factory; + const resourceImportDeclarations: ts.ImportDeclaration[] = []; + + const visitNode: ts.Visitor = (node: ts.Node) => { + if (ts.isClassDeclaration(node)) { + const decorators = ts.getDecorators(node); + + if (!decorators || decorators.length === 0) { + return node; + } + + return nodeFactory.updateClassDeclaration( + node, + [ + ...decorators.map((current) => + visitDecorator(nodeFactory, current, typeChecker, resourceImportDeclarations), + ), + ...(ts.getModifiers(node) ?? []), + ], + node.name, + node.typeParameters, + node.heritageClauses, + node.members, + ); + } + + return ts.visitEachChild(node, visitNode, context); + }; + + return (sourceFile) => { + const updatedSourceFile = ts.visitEachChild(sourceFile, visitNode, context); + + if (resourceImportDeclarations.length > 0) { + return nodeFactory.updateSourceFile( + updatedSourceFile, + ts.setTextRange( + nodeFactory.createNodeArray( + [...resourceImportDeclarations, ...updatedSourceFile.statements], + updatedSourceFile.statements.hasTrailingComma, + ), + updatedSourceFile.statements, + ), + updatedSourceFile.isDeclarationFile, + updatedSourceFile.referencedFiles, + updatedSourceFile.typeReferenceDirectives, + updatedSourceFile.hasNoDefaultLib, + updatedSourceFile.libReferenceDirectives, + ); + } else { + return updatedSourceFile; + } + }; + }; +} + +function visitDecorator( + nodeFactory: ts.NodeFactory, + node: ts.Decorator, + typeChecker: ts.TypeChecker, + resourceImportDeclarations: ts.ImportDeclaration[], +): ts.Decorator { + const origin = getDecoratorOrigin(node, typeChecker); + if (!origin || origin.module !== '@angular/core' || origin.name !== 'Component') { + return node; + } + + if (!ts.isCallExpression(node.expression)) { + return node; + } + + const decoratorFactory = node.expression; + const args = decoratorFactory.arguments; + if (args.length !== 1 || !ts.isObjectLiteralExpression(args[0])) { + // Unsupported component metadata + return node; + } + + const objectExpression = args[0]; + const styleReplacements: ts.Expression[] = []; + + // visit all properties + let properties = ts.visitNodes(objectExpression.properties, (node) => + ts.isObjectLiteralElementLike(node) + ? visitComponentMetadata(nodeFactory, node, styleReplacements, resourceImportDeclarations) + : node, + ) as ts.NodeArray; + + // replace properties with updated properties + if (styleReplacements.length > 0) { + const styleProperty = nodeFactory.createPropertyAssignment( + nodeFactory.createIdentifier('styles'), + nodeFactory.createArrayLiteralExpression(styleReplacements), + ); + + properties = nodeFactory.createNodeArray([...properties, styleProperty]); + } + + return nodeFactory.updateDecorator( + node, + nodeFactory.updateCallExpression( + decoratorFactory, + decoratorFactory.expression, + decoratorFactory.typeArguments, + [nodeFactory.updateObjectLiteralExpression(objectExpression, properties)], + ), + ); +} + +function visitComponentMetadata( + nodeFactory: ts.NodeFactory, + node: ts.ObjectLiteralElementLike, + styleReplacements: ts.Expression[], + resourceImportDeclarations: ts.ImportDeclaration[], +): ts.ObjectLiteralElementLike | undefined { + if (!ts.isPropertyAssignment(node) || ts.isComputedPropertyName(node.name)) { + return node; + } + + switch (node.name.text) { + case 'templateUrl': + // Only analyze string literals + if (!ts.isStringLiteralLike(node.initializer)) { + return node; + } + + return node.initializer.text.length === 0 + ? node + : nodeFactory.updatePropertyAssignment( + node, + nodeFactory.createIdentifier('template'), + createResourceImport( + nodeFactory, + generateJitFileUri(node.initializer.text, 'template'), + resourceImportDeclarations, + ), + ); + case 'styles': + if (ts.isStringLiteralLike(node.initializer)) { + styleReplacements.unshift( + createResourceImport( + nodeFactory, + generateJitInlineUri(node.initializer.text, 'style'), + resourceImportDeclarations, + ), + ); + + return undefined; + } + + if (ts.isArrayLiteralExpression(node.initializer)) { + const inlineStyles = ts.visitNodes(node.initializer.elements, (node) => { + if (!ts.isStringLiteralLike(node)) { + return node; + } + + return node.text.length === 0 + ? undefined // An empty inline style is equivalent to not having a style element + : createResourceImport( + nodeFactory, + generateJitInlineUri(node.text, 'style'), + resourceImportDeclarations, + ); + }) as ts.NodeArray; + + // Inline styles should be placed first + styleReplacements.unshift(...inlineStyles); + + // The inline styles will be added afterwards in combination with any external styles + return undefined; + } + + return node; + + case 'styleUrl': + if (ts.isStringLiteralLike(node.initializer)) { + styleReplacements.push( + createResourceImport( + nodeFactory, + generateJitFileUri(node.initializer.text, 'style'), + resourceImportDeclarations, + ), + ); + + return undefined; + } + + return node; + + case 'styleUrls': { + if (!ts.isArrayLiteralExpression(node.initializer)) { + return node; + } + + const externalStyles = ts.visitNodes(node.initializer.elements, (node) => { + if (!ts.isStringLiteralLike(node)) { + return node; + } + + return node.text + ? createResourceImport( + nodeFactory, + generateJitFileUri(node.text, 'style'), + resourceImportDeclarations, + ) + : undefined; + }) as ts.NodeArray; + + // External styles are applied after any inline styles + styleReplacements.push(...externalStyles); + + // The external styles will be added afterwards in combination with any inline styles + return undefined; + } + default: + // All other elements are passed through + return node; + } +} + +function createResourceImport( + nodeFactory: ts.NodeFactory, + url: string, + resourceImportDeclarations: ts.ImportDeclaration[], +): ts.Identifier { + const urlLiteral = nodeFactory.createStringLiteral(url); + + const importName = nodeFactory.createIdentifier( + `__NG_CLI_RESOURCE__${resourceImportDeclarations.length}`, + ); + resourceImportDeclarations.push( + nodeFactory.createImportDeclaration( + undefined, + nodeFactory.createImportClause(undefined, importName, undefined), + urlLiteral, + ), + ); + + return importName; +} + +function getDecoratorOrigin( + decorator: ts.Decorator, + typeChecker: ts.TypeChecker, +): { name: string; module: string } | null { + if (!ts.isCallExpression(decorator.expression)) { + return null; + } + + let identifier: ts.Node; + let name = ''; + + if (ts.isPropertyAccessExpression(decorator.expression.expression)) { + identifier = decorator.expression.expression.expression; + name = decorator.expression.expression.name.text; + } else if (ts.isIdentifier(decorator.expression.expression)) { + identifier = decorator.expression.expression; + } else { + return null; + } + + // NOTE: resolver.getReferencedImportDeclaration would work as well but is internal + const symbol = typeChecker.getSymbolAtLocation(identifier); + if (symbol && symbol.declarations && symbol.declarations.length > 0) { + const declaration = symbol.declarations[0]; + let module: string; + + if (ts.isImportSpecifier(declaration)) { + name = (declaration.propertyName || declaration.name).text; + module = (declaration.parent.parent.parent.moduleSpecifier as ts.StringLiteral).text; + } else if (ts.isNamespaceImport(declaration)) { + // Use the name from the decorator namespace property access + module = (declaration.parent.parent.moduleSpecifier as ts.StringLiteral).text; + } else if (ts.isImportClause(declaration)) { + name = (declaration.name as ts.Identifier).text; + module = (declaration.parent.moduleSpecifier as ts.StringLiteral).text; + } else { + return null; + } + + return { name, module }; + } + + return null; +} diff --git a/packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer.ts b/packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer.ts new file mode 100644 index 000000000000..10d45d00d714 --- /dev/null +++ b/packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { relative } from 'node:path/posix'; +import ts from 'typescript'; + +/** + * A transformer factory that adds a property to the lazy-loaded route object. + * This property is used to allow for the retrieval of the module path during SSR. + * + * @param compilerOptions The compiler options. + * @param compilerHost The compiler host. + * @returns A transformer factory. + * + * @example + * **Before:** + * ```ts + * const routes: Routes = [ + * { + * path: 'lazy', + * loadChildren: () => import('./lazy/lazy.module').then(m => m.LazyModule) + * } + * ]; + * ``` + * + * **After:** + * ```ts + * const routes: Routes = [ + * { + * path: 'lazy', + * loadChildren: () => import('./lazy/lazy.module').then(m => m.LazyModule), + * ...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "./lazy/lazy.module.ts" }: {}) + * } + * ]; + * ``` + */ +export function lazyRoutesTransformer( + compilerOptions: ts.CompilerOptions, + compilerHost: ts.CompilerHost, +): ts.TransformerFactory { + const moduleResolutionCache = compilerHost.getModuleResolutionCache?.(); + assert( + typeof compilerOptions.basePath === 'string', + 'compilerOptions.basePath should be a string.', + ); + const basePath = compilerOptions.basePath; + + return (context: ts.TransformationContext) => { + const factory = context.factory; + + const visitor = (node: ts.Node): ts.Node => { + if (!ts.isObjectLiteralExpression(node)) { + // Not an object literal, so skip it. + return ts.visitEachChild(node, visitor, context); + } + + const loadFunction = getLoadComponentOrChildrenProperty(node)?.initializer; + // Check if the initializer is an arrow function or a function expression + if ( + !loadFunction || + (!ts.isArrowFunction(loadFunction) && !ts.isFunctionExpression(loadFunction)) + ) { + return ts.visitEachChild(node, visitor, context); + } + + let callExpression: ts.CallExpression | undefined; + + if (ts.isArrowFunction(loadFunction)) { + // Handle arrow functions: body can either be a block or a direct call expression + const body = loadFunction.body; + + if (ts.isBlock(body)) { + // Arrow function with a block: check the first statement for a return call expression + const firstStatement = body.statements[0]; + + if ( + firstStatement && + ts.isReturnStatement(firstStatement) && + firstStatement.expression && + ts.isCallExpression(firstStatement.expression) + ) { + callExpression = firstStatement.expression; + } + } else if (ts.isCallExpression(body)) { + // Arrow function with a direct call expression as its body + callExpression = body; + } + } else if (ts.isFunctionExpression(loadFunction)) { + // Handle function expressions: check for a return statement with a call expression + const returnExpression = loadFunction.body.statements.find( + ts.isReturnStatement, + )?.expression; + + if (returnExpression && ts.isCallExpression(returnExpression)) { + callExpression = returnExpression; + } + } + + if (!callExpression) { + return ts.visitEachChild(node, visitor, context); + } + + // Optionally check for the 'then' property access expression + const expression = callExpression.expression; + if ( + !ts.isCallExpression(expression) && + ts.isPropertyAccessExpression(expression) && + expression.name.text !== 'then' + ) { + return ts.visitEachChild(node, visitor, context); + } + + const importExpression = ts.isPropertyAccessExpression(expression) + ? expression.expression // Navigate to the underlying expression for 'then' + : callExpression; + + // Ensure the underlying expression is an import call + if ( + !ts.isCallExpression(importExpression) || + importExpression.expression.kind !== ts.SyntaxKind.ImportKeyword + ) { + return ts.visitEachChild(node, visitor, context); + } + + // Check if the argument to the import call is a string literal + const callExpressionArgument = importExpression.arguments[0]; + if (!ts.isStringLiteralLike(callExpressionArgument)) { + // Not a string literal, so skip it. + return ts.visitEachChild(node, visitor, context); + } + + const resolvedPath = ts.resolveModuleName( + callExpressionArgument.text, + node.getSourceFile().fileName, + compilerOptions, + compilerHost, + moduleResolutionCache, + )?.resolvedModule?.resolvedFileName; + + if (!resolvedPath) { + // Could not resolve the module, so skip it. + return ts.visitEachChild(node, visitor, context); + } + + const resolvedRelativePath = relative(basePath, resolvedPath); + + // Create the new property + // Example: `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" }: {})` + const newProperty = factory.createSpreadAssignment( + factory.createParenthesizedExpression( + factory.createConditionalExpression( + factory.createBinaryExpression( + factory.createBinaryExpression( + factory.createTypeOfExpression(factory.createIdentifier('ngServerMode')), + factory.createToken(ts.SyntaxKind.ExclamationEqualsEqualsToken), + factory.createStringLiteral('undefined'), + ), + factory.createToken(ts.SyntaxKind.AmpersandAmpersandToken), + factory.createIdentifier('ngServerMode'), + ), + factory.createToken(ts.SyntaxKind.QuestionToken), + factory.createObjectLiteralExpression([ + factory.createPropertyAssignment( + factory.createIdentifier('ɵentryName'), + factory.createStringLiteral(resolvedRelativePath), + ), + ]), + factory.createToken(ts.SyntaxKind.ColonToken), + factory.createObjectLiteralExpression([]), + ), + ), + ); + + // Add the new property to the object literal. + return factory.updateObjectLiteralExpression(node, [...node.properties, newProperty]); + }; + + return (sourceFile) => { + const text = sourceFile.text; + if (!text.includes('loadC')) { + // Fast check for 'loadComponent' and 'loadChildren'. + return sourceFile; + } + + return ts.visitEachChild(sourceFile, visitor, context); + }; + }; +} + +/** + * Retrieves the property assignment for the `loadComponent` or `loadChildren` property of a route object. + * + * @param node The object literal expression to search. + * @returns The property assignment if found, otherwise `undefined`. + */ +function getLoadComponentOrChildrenProperty( + node: ts.ObjectLiteralExpression, +): ts.PropertyAssignment | undefined { + let hasPathProperty = false; + let loadComponentOrChildrenProperty: ts.PropertyAssignment | undefined; + for (const prop of node.properties) { + if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) { + continue; + } + + const propertyNameText = prop.name.text; + if (propertyNameText === 'path') { + hasPathProperty = true; + } else if (propertyNameText === 'loadComponent' || propertyNameText === 'loadChildren') { + loadComponentOrChildrenProperty = prop; + } + + if (hasPathProperty && loadComponentOrChildrenProperty) { + break; + } + } + + return loadComponentOrChildrenProperty; +} diff --git a/packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer_spec.ts b/packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer_spec.ts new file mode 100644 index 000000000000..4dd388f28eb1 --- /dev/null +++ b/packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer_spec.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import ts from 'typescript'; +import { lazyRoutesTransformer } from './lazy-routes-transformer'; + +describe('lazyRoutesTransformer', () => { + let program: ts.Program; + let compilerHost: ts.CompilerHost; + + beforeEach(() => { + // Mock a basic TypeScript program and compilerHost + program = ts.createProgram(['/project/src/dummy.ts'], { basePath: '/project/' }); + compilerHost = { + getNewLine: () => '\n', + fileExists: () => true, + readFile: () => '', + writeFile: () => undefined, + getCanonicalFileName: (fileName: string) => fileName, + getCurrentDirectory: () => '/project', + getDefaultLibFileName: () => 'lib.d.ts', + getSourceFile: () => undefined, + useCaseSensitiveFileNames: () => true, + resolveModuleNames: (moduleNames, containingFile) => + moduleNames.map( + (name) => + ({ + resolvedFileName: `/project/src/${name}.ts`, + }) as ts.ResolvedModule, + ), + }; + }); + + const transformSourceFile = (sourceCode: string): ts.SourceFile => { + const sourceFile = ts.createSourceFile( + '/project/src/dummy.ts', + sourceCode, + ts.ScriptTarget.ESNext, + true, + ts.ScriptKind.TS, + ); + + const transformer = lazyRoutesTransformer(program.getCompilerOptions(), compilerHost); + const result = ts.transform(sourceFile, [transformer]); + + return result.transformed[0]; + }; + + it('should return the same object when the routes array contains an empty object', () => { + const source = ` + const routes = [{}]; + `; + + const transformedSourceFile = transformSourceFile(source); + const transformedCode = ts.createPrinter().printFile(transformedSourceFile); + + expect(transformedCode).toContain(`const routes = [{}]`); + }); + + it('should add ɵentryName property to object with loadComponent and path (Arrow function)', () => { + const source = ` + const routes = [ + { + path: 'home', + loadComponent: () => import('./home').then(m => m.HomeComponent) + } + ]; + `; + + const transformedSourceFile = transformSourceFile(source); + const transformedCode = ts.createPrinter().printFile(transformedSourceFile); + + expect(transformedCode).toContain( + `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" } : {})`, + ); + }); + + it('should add ɵentryName property to object with loadComponent and path (Arrow function with return)', () => { + const source = ` + const routes = [ + { + path: 'home', + loadComponent: () => { + return import('./home').then(m => m.HomeComponent); + } + } + ]; + `; + + const transformedSourceFile = transformSourceFile(source); + const transformedCode = ts.createPrinter().printFile(transformedSourceFile); + + expect(transformedCode).toContain( + `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" } : {})`, + ); + }); + + it('should add ɵentryName property to object with loadComponent and path (Arrow function without .then)', () => { + const source = ` + const routes = [ + { + path: 'about', + loadComponent: () => import('./about') + } + ]; + `; + + const transformedSourceFile = transformSourceFile(source); + const transformedCode = ts.createPrinter().printFile(transformedSourceFile); + + expect(transformedCode).toContain( + `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/about.ts" } : {})`, + ); + }); + + it('should add ɵentryName property to object with loadComponent using return and .then', () => { + const source = ` + const routes = [ + { + path: '', + loadComponent: () => { + return import('./home').then((m) => m.HomeComponent); + } + } + ]; + `; + + const transformedSourceFile = transformSourceFile(source); + const transformedCode = ts.createPrinter().printFile(transformedSourceFile); + + expect(transformedCode).toContain( + `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" } : {})`, + ); + }); + + it('should add ɵentryName property to object with loadComponent and path (Function expression)', () => { + const source = ` + const routes = [ + { + path: 'home', + loadComponent: function () { return import('./home').then(m => m.HomeComponent) } + } + ]; + `; + + const transformedSourceFile = transformSourceFile(source); + const transformedCode = ts.createPrinter().printFile(transformedSourceFile); + + expect(transformedCode).toContain( + `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" } : {})`, + ); + }); + + it('should not modify unrelated object literals', () => { + const source = ` + const routes = [ + { + path: 'home', + component: HomeComponent + } + ]; + `; + + const transformedSourceFile = transformSourceFile(source); + const transformedCode = ts.createPrinter().printFile(transformedSourceFile); + + expect(transformedCode).not.toContain(`ɵentryName`); + }); + + it('should ignore loadComponent without a valid import call', () => { + const source = ` + const routes = [ + { + path: 'home', + loadComponent: () => someFunction() + } + ]; + `; + + const transformedSourceFile = transformSourceFile(source); + const transformedCode = ts.createPrinter().printFile(transformedSourceFile); + + expect(transformedCode).not.toContain(`ɵentryName`); + }); + + it('should resolve paths relative to basePath', () => { + const source = ` + const routes = [ + { + path: 'about', + loadChildren: () => import('./features/about').then(m => m.AboutModule) + } + ]; + `; + + const transformedSourceFile = transformSourceFile(source); + const transformedCode = ts.createPrinter().printFile(transformedSourceFile); + + expect(transformedCode).toContain( + `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/features/about.ts" } : {})`, + ); + }); +}); diff --git a/packages/angular/build/src/tools/angular/transformers/web-worker-transformer.ts b/packages/angular/build/src/tools/angular/transformers/web-worker-transformer.ts new file mode 100644 index 000000000000..329320843838 --- /dev/null +++ b/packages/angular/build/src/tools/angular/transformers/web-worker-transformer.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import ts from 'typescript'; + +/** + * Creates a TypeScript Transformer to process Worker and SharedWorker entry points and transform + * the URL instances to reference the built and bundled worker code. This uses a callback process + * similar to the component stylesheets to allow the main esbuild plugin to process files as needed. + * Unsupported worker expressions will be left in their origin form. + * @param getTypeChecker A function that returns a TypeScript TypeChecker instance for the program. + * @returns A TypeScript transformer factory. + */ +export function createWorkerTransformer( + fileProcessor: (file: string, importer: string) => string, +): ts.TransformerFactory { + return (context: ts.TransformationContext) => { + const nodeFactory = context.factory; + + const visitNode: ts.Visitor = (node: ts.Node) => { + // Check if the node is a valid new expression for a Worker or SharedWorker + // TODO: Add global scope check + if ( + !ts.isNewExpression(node) || + !ts.isIdentifier(node.expression) || + (node.expression.text !== 'Worker' && node.expression.text !== 'SharedWorker') + ) { + // Visit child nodes of non-Worker expressions + return ts.visitEachChild(node, visitNode, context); + } + + // Worker should have atleast one argument but not more than two + if (!node.arguments || node.arguments.length < 1 || node.arguments.length > 2) { + return node; + } + + // First argument must be a new URL expression + const workerUrlNode = node.arguments[0]; + // TODO: Add global scope check + if ( + !ts.isNewExpression(workerUrlNode) || + !ts.isIdentifier(workerUrlNode.expression) || + workerUrlNode.expression.text !== 'URL' + ) { + return node; + } + + // URL must have 2 arguments + if (!workerUrlNode.arguments || workerUrlNode.arguments.length !== 2) { + return node; + } + + // URL arguments must be a string and then `import.meta.url` + if ( + !ts.isStringLiteralLike(workerUrlNode.arguments[0]) || + !ts.isPropertyAccessExpression(workerUrlNode.arguments[1]) || + !ts.isMetaProperty(workerUrlNode.arguments[1].expression) || + workerUrlNode.arguments[1].name.text !== 'url' + ) { + return node; + } + + const filePath = workerUrlNode.arguments[0].text; + const importer = node.getSourceFile().fileName; + + // Process the file + const replacementPath = fileProcessor(filePath, importer); + + // Update if the path changed + if (replacementPath !== filePath) { + return nodeFactory.updateNewExpression( + node, + node.expression, + node.typeArguments, + // Update Worker arguments + ts.setTextRange( + nodeFactory.createNodeArray( + [ + nodeFactory.updateNewExpression( + workerUrlNode, + workerUrlNode.expression, + workerUrlNode.typeArguments, + // Update URL arguments + ts.setTextRange( + nodeFactory.createNodeArray( + [ + nodeFactory.createStringLiteral(replacementPath), + workerUrlNode.arguments[1], + ], + workerUrlNode.arguments.hasTrailingComma, + ), + workerUrlNode.arguments, + ), + ), + // Use the second Worker argument (options) if present. + // Otherwise create a default options object for module Workers. + node.arguments[1] ?? + nodeFactory.createObjectLiteralExpression([ + nodeFactory.createPropertyAssignment( + 'type', + nodeFactory.createStringLiteral('module'), + ), + ]), + ], + node.arguments.hasTrailingComma, + ), + node.arguments, + ), + ); + } else { + return node; + } + }; + + return (sourceFile) => { + // Skip transformer if there are no Workers + if (!sourceFile.text.includes('Worker')) { + return sourceFile; + } + + return ts.visitEachChild(sourceFile, visitNode, context); + }; + }; +} diff --git a/packages/angular/build/src/tools/angular/uri.ts b/packages/angular/build/src/tools/angular/uri.ts new file mode 100644 index 000000000000..0b662f58c670 --- /dev/null +++ b/packages/angular/build/src/tools/angular/uri.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * A string value representing the base namespace for Angular JIT mode related imports. + */ +const JIT_BASE_NAMESPACE = 'angular:jit'; + +/** + * A string value representing the namespace for Angular JIT mode related imports for + * Component styles. This namespace is used for both inline (`styles`) and external + * (`styleUrls`) styles. + */ +export const JIT_STYLE_NAMESPACE = `${JIT_BASE_NAMESPACE}:style` as const; + +/** + * A string value representing the namespace for Angular JIT mode related imports for + * Component templates. This namespace is currently only used for external (`templateUrl`) + * templates. + */ +export const JIT_TEMPLATE_NAMESPACE = `${JIT_BASE_NAMESPACE}:template` as const; + +/** + * A regular expression that can be used to match a Angular JIT mode namespace URI. + * It contains capture groups for the type (template/style), origin (file/inline), and specifier. + * The {@link parseJitUri} function can be used to parse and return an object representation of a JIT URI. + */ +export const JIT_NAMESPACE_REGEXP = new RegExp( + `^${JIT_BASE_NAMESPACE}:(template|style):(file|inline);(.*)$`, +); + +/** + * Generates an Angular JIT mode namespace URI for a given file. + * @param file The path of the file to be included. + * @param type The type of the file (`style` or `template`). + * @returns A string containing the full JIT namespace URI. + */ +export function generateJitFileUri(file: string, type: 'style' | 'template') { + return `${JIT_BASE_NAMESPACE}:${type}:file;${file}`; +} + +/** + * Generates an Angular JIT mode namespace URI for a given inline style or template. + * The provided content is base64 encoded and included in the URI. + * @param data The content to encode within the URI. + * @param type The type of the content (`style` or `template`). + * @returns A string containing the full JIT namespace URI. + */ +export function generateJitInlineUri(data: string | Uint8Array, type: 'style' | 'template') { + // Node.js types do not allow a string even though this is valid. + return `${JIT_BASE_NAMESPACE}:${type}:inline;${Buffer.from(data as Uint8Array).toString('base64')}`; +} + +/** + * Parses a string containing a JIT namespace URI. + * JIT namespace URIs are used to encode the information for an Angular component's stylesheets + * and templates when compiled in JIT mode. + * @param uri The URI to parse into its underlying components. + * @returns An object containing the namespace, type, origin, and specifier of the URI; + * `undefined` if not a JIT namespace URI. + */ +export function parseJitUri(uri: string) { + const matches = JIT_NAMESPACE_REGEXP.exec(uri); + if (!matches) { + return undefined; + } + + return { + namespace: `${JIT_BASE_NAMESPACE}:${matches[1]}`, + type: matches[1] as 'style' | 'template', + origin: matches[2] as 'file' | 'inline', + specifier: matches[3], + }; +} diff --git a/packages/angular/build/src/tools/esbuild/angular-localize-init-warning-plugin.ts b/packages/angular/build/src/tools/esbuild/angular-localize-init-warning-plugin.ts new file mode 100644 index 000000000000..d0aa9aff841d --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular-localize-init-warning-plugin.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Plugin } from 'esbuild'; + +const NG_LOCALIZE_RESOLUTION = Symbol('NG_LOCALIZE_RESOLUTION'); + +/** + * This plugin addresses an issue where '@angular/localize/init' is directly imported, + * potentially resulting in undefined behavior. By detecting such imports, the plugin + * issues a warning and suggests including '@angular/localize/init' as a polyfill. + * + * @returns An esbuild plugin. + */ +export function createAngularLocalizeInitWarningPlugin(): Plugin { + return { + name: 'angular-localize-init-warning', + setup(build) { + build.onResolve({ filter: /^@angular\/localize\/init/ }, async (args) => { + if (args.pluginData?.[NG_LOCALIZE_RESOLUTION]) { + return null; + } + + if (args.namespace?.startsWith('angular:')) { + return null; + } + + const { importer, kind, resolveDir, namespace, pluginData = {} } = args; + pluginData[NG_LOCALIZE_RESOLUTION] = true; + + const result = await build.resolve(args.path, { + importer, + kind, + namespace, + pluginData, + resolveDir, + }); + + return { + ...result, + warnings: [ + ...result.warnings, + { + text: `Direct import of '@angular/localize/init' detected. This may lead to undefined behavior.`, + notes: [{ text: `Include '@angular/localize/init' as a polyfill instead.` }], + }, + ], + }; + }); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/angular/compilation-state.ts b/packages/angular/build/src/tools/esbuild/angular/compilation-state.ts new file mode 100644 index 000000000000..541f52bbca76 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/compilation-state.ts @@ -0,0 +1,143 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { CompilerOptions } from '@angular/compiler-cli'; +import type { AngularCompilation } from '../../angular/compilation'; + +export abstract class AngularCompilationContext { + abstract readonly compilation?: AngularCompilation; + abstract isPrimary(): this is PrimaryCompilationContext; + abstract readonly waitUntilReady: Promise; + abstract getCompilerOptions(): Promise; + abstract dispose(): Promise; + + createSecondaryContext(): AngularCompilationContext { + return new SecondaryCompilationContext(this); + } +} + +export class PrimaryCompilationContext extends AngularCompilationContext { + readonly #compilation: AngularCompilation; + #pendingCompilation = true; + #resolveCompilationReady: ((value: boolean) => void) | undefined; + #compilationReadyPromise: Promise | undefined; + #hasErrors = true; + + #compilerOptions: CompilerOptions | undefined; + #resolveCompilerOptions: ((options: CompilerOptions) => void) | undefined; + #compilerOptionsPromise: Promise | undefined; + + constructor(compilation: AngularCompilation) { + super(); + this.#compilation = compilation; + } + + override isPrimary(): this is PrimaryCompilationContext { + return true; + } + + override get compilation(): AngularCompilation { + return this.#compilation; + } + + override get waitUntilReady(): Promise { + if (!this.#pendingCompilation) { + return Promise.resolve(this.#hasErrors); + } + + this.#compilationReadyPromise ??= new Promise((resolve) => { + this.#resolveCompilationReady = resolve; + }); + + return this.#compilationReadyPromise; + } + + override getCompilerOptions(): Promise { + if (this.#compilerOptions) { + return Promise.resolve(this.#compilerOptions); + } + + if (!this.#pendingCompilation) { + return Promise.resolve({}); + } + + this.#compilerOptionsPromise ??= new Promise((resolve) => { + this.#resolveCompilerOptions = resolve; + }); + + return this.#compilerOptionsPromise; + } + + setCompilerOptions(options: CompilerOptions): void { + this.#compilerOptions = options; + this.#resolveCompilerOptions?.(options); + this.#resolveCompilerOptions = undefined; + this.#compilerOptionsPromise = undefined; + } + + markAsReady(hasErrors: boolean): void { + this.#hasErrors = hasErrors; + this.#resolveCompilationReady?.(hasErrors); + this.#resolveCompilationReady = undefined; + this.#compilationReadyPromise = undefined; + this.#pendingCompilation = false; + + if (this.#resolveCompilerOptions) { + this.#resolveCompilerOptions(this.#compilerOptions ?? {}); + this.#resolveCompilerOptions = undefined; + this.#compilerOptionsPromise = undefined; + } + } + + markAsInProgress(): void { + this.#pendingCompilation = true; + this.#compilerOptions = undefined; + } + + #disposal: Promise | undefined; + + override dispose(): Promise { + // Reuse any in progress disposal to ensure all callers can await completion + return (this.#disposal ??= this.#close()); + } + + async #close(): Promise { + this.markAsReady(true); + try { + await this.#compilation.close?.(); + } catch { + // Suppress closure errors to avoid unhandled rejections during teardown. + } + } +} + +export class SecondaryCompilationContext extends AngularCompilationContext { + constructor(private readonly primaryContext?: AngularCompilationContext) { + super(); + } + + override isPrimary(): this is PrimaryCompilationContext { + return false; + } + + override get compilation(): undefined { + return undefined; + } + + override get waitUntilReady(): Promise { + return this.primaryContext?.waitUntilReady ?? Promise.resolve(false); + } + + override getCompilerOptions(): Promise { + return this.primaryContext?.getCompilerOptions() ?? Promise.resolve({}); + } + + override async dispose(): Promise { + // No-op for secondary context to avoid disposing the primary compilation worker + } +} diff --git a/packages/angular/build/src/tools/esbuild/angular/compilation-state_spec.ts b/packages/angular/build/src/tools/esbuild/angular/compilation-state_spec.ts new file mode 100644 index 000000000000..8007427d497c --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/compilation-state_spec.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { CompilerOptions } from '@angular/compiler-cli'; +import type { AngularCompilation } from '../../angular/compilation'; +import { PrimaryCompilationContext, SecondaryCompilationContext } from './compilation-state'; + +describe('compilation-state', () => { + let mockCompilation: jasmine.SpyObj; + + beforeEach(() => { + mockCompilation = jasmine.createSpyObj('AngularCompilation', [ + 'initialize', + 'close', + ]); + }); + + describe('PrimaryCompilationContext', () => { + it('identifies as primary and provides the underlying compilation', () => { + const context = new PrimaryCompilationContext(mockCompilation); + + expect(context.isPrimary()).toBe(true); + expect(context.compilation).toBe(mockCompilation); + }); + + it('resolves getCompilerOptions after setCompilerOptions is called', async () => { + const context = new PrimaryCompilationContext(mockCompilation); + const options: CompilerOptions = { target: 9, allowJs: true }; + + context.setCompilerOptions(options); + + const resolved = await context.getCompilerOptions(); + expect(resolved).toEqual(options); + }); + + it('resolves pending getCompilerOptions when setCompilerOptions is called later', async () => { + const context = new PrimaryCompilationContext(mockCompilation); + const options: CompilerOptions = { target: 9, allowJs: true }; + + const optionsPromise = context.getCompilerOptions(); + context.setCompilerOptions(options); + + const resolved = await optionsPromise; + expect(resolved).toEqual(options); + }); + + it('manages ready state lifecycle correctly', async () => { + const context = new PrimaryCompilationContext(mockCompilation); + + // Initially in progress + const readyPromise = context.waitUntilReady; + context.markAsReady(false); + + const hasErrors = await readyPromise; + expect(hasErrors).toBe(false); + + // When already ready, immediately returns error state + expect(await context.waitUntilReady).toBe(false); + + // Mark in progress again + context.markAsInProgress(); + const secondReadyPromise = context.waitUntilReady; + context.markAsReady(true); + + expect(await secondReadyPromise).toBe(true); + }); + + it('closes compilation and marks ready with errors on dispose', async () => { + const context = new PrimaryCompilationContext(mockCompilation); + + const readyPromise = context.waitUntilReady; + await context.dispose(); + + expect(mockCompilation.close).toHaveBeenCalledTimes(1); + expect(await readyPromise).toBe(true); + }); + + it('unblocks pending getCompilerOptions on markAsReady error', async () => { + const context = new PrimaryCompilationContext(mockCompilation); + + const optionsPromise = context.getCompilerOptions(); + context.markAsReady(true); + + const options = await optionsPromise; + expect(options).toEqual({}); + }); + + it('returns empty options from getCompilerOptions when compilation is not pending and options are unset', async () => { + const context = new PrimaryCompilationContext(mockCompilation); + + context.markAsReady(false); + + const options = await context.getCompilerOptions(); + expect(options).toEqual({}); + }); + + it('clears cached compiler options on markAsInProgress to wait for fresh options on rebuild', async () => { + const context = new PrimaryCompilationContext(mockCompilation); + const initialOptions: CompilerOptions = { target: 9, allowJs: false }; + context.setCompilerOptions(initialOptions); + context.markAsReady(false); + + expect(await context.getCompilerOptions()).toEqual(initialOptions); + + // Rebuild starts + context.markAsInProgress(); + + // Pending call should wait for new options rather than returning stale ones + const nextOptionsPromise = context.getCompilerOptions(); + const updatedOptions: CompilerOptions = { target: 10, allowJs: true }; + context.setCompilerOptions(updatedOptions); + + expect(await nextOptionsPromise).toEqual(updatedOptions); + }); + + it('creates a linked SecondaryCompilationContext', () => { + const context = new PrimaryCompilationContext(mockCompilation); + const secondary = context.createSecondaryContext(); + + expect(secondary).toBeInstanceOf(SecondaryCompilationContext); + expect(secondary.isPrimary()).toBe(false); + expect(secondary.compilation).toBeUndefined(); + }); + }); + + describe('SecondaryCompilationContext', () => { + it('identifies as non-primary and has undefined compilation', () => { + const primary = new PrimaryCompilationContext(mockCompilation); + const secondary = primary.createSecondaryContext(); + + expect(secondary.isPrimary()).toBe(false); + expect(secondary.compilation).toBeUndefined(); + }); + + it('delegates waitUntilReady to primary context', async () => { + const primary = new PrimaryCompilationContext(mockCompilation); + const secondary = primary.createSecondaryContext(); + + const secondaryReadyPromise = secondary.waitUntilReady; + primary.markAsReady(false); + + expect(await secondaryReadyPromise).toBe(false); + }); + + it('delegates getCompilerOptions to primary context', async () => { + const primary = new PrimaryCompilationContext(mockCompilation); + const secondary = primary.createSecondaryContext(); + const options: CompilerOptions = { target: 9, allowJs: false }; + + primary.setCompilerOptions(options); + + expect(await secondary.getCompilerOptions()).toEqual(options); + }); + + it('does not dispose primary compilation when secondary is disposed', async () => { + const primary = new PrimaryCompilationContext(mockCompilation); + const secondary = primary.createSecondaryContext(); + + await secondary.dispose(); + + expect(mockCompilation.close).not.toHaveBeenCalled(); + }); + + it('handles standalone instantiation with sensible defaults', async () => { + const standalone = new SecondaryCompilationContext(); + + expect(standalone.isPrimary()).toBe(false); + expect(standalone.compilation).toBeUndefined(); + expect(await standalone.waitUntilReady).toBe(false); + expect(await standalone.getCompilerOptions()).toEqual({}); + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts new file mode 100644 index 000000000000..23e7e88dba74 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts @@ -0,0 +1,803 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { + BuildFailure, + Loader, + Metafile, + OnStartResult, + OutputFile, + PartialMessage, + PartialNote, + Plugin, + PluginBuild, +} from 'esbuild'; +import assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import * as path from 'node:path'; +import { type PersistentCacheStore, createPersistentCacheStore } from '../../../utils/cache'; +import { maxTransformWorkers, useTypeChecking } from '../../../utils/environment-options'; +import { calculateHash, initializeHash } from '../../../utils/hash'; +import { + logCumulativeDurations, + profileAsync, + resetCumulativeDurations, +} from '../../../utils/profiling'; +import { AngularHostOptions } from '../../angular/angular-host'; +import { AngularCompilation, DiagnosticModes } from '../../angular/compilation'; +import { JavaScriptTransformer } from '../../javascript-transformer'; +import { LoadResultCache, createCachedLoad } from '../load-result-cache'; +import { AngularCompilationContext, PrimaryCompilationContext } from './compilation-state'; +import { ComponentStylesheetBundler } from './component-stylesheets'; +import { FileReferenceTracker } from './file-reference-tracker'; +import { setupJitPluginCallbacks } from './jit-plugin-callbacks'; +import { rewriteForBazel } from './rewrite-bazel-paths'; +import { SourceFileCache } from './source-file-cache'; + +export interface CompilerPluginOptions { + sourcemap: boolean | 'external'; + tsconfig: string; + jit?: boolean; + + /** + * Include class metadata and JIT information in built code. + * The Angular TestBed APIs require additional metadata for the Angular aspects of the application + * such as Components, Modules, Pipes, etc. + * TestBed may also leverage JIT capabilities during testing (e.g., overrideComponent). + */ + includeTestMetadata?: boolean; + + advancedOptimizations?: boolean; + thirdPartySourcemaps?: boolean; + fileReplacements?: Record; + sourceFileCache?: SourceFileCache; + loadResultCache?: LoadResultCache; + incremental: boolean; + externalRuntimeStyles?: boolean; + instrumentForCoverage?: (request: string) => boolean; + templateUpdates?: Map; + rootFiles?: string[]; +} + +// eslint-disable-next-line max-lines-per-function +export function createCompilerPlugin( + pluginOptions: CompilerPluginOptions, + compilationContextOrCompilation: + AngularCompilationContext | AngularCompilation | (() => Promise), + stylesheetBundler: ComponentStylesheetBundler, +): Plugin { + return { + name: 'angular-compiler', + // eslint-disable-next-line max-lines-per-function + async setup(build: PluginBuild): Promise { + let setupWarnings: PartialMessage[] | undefined = []; + let diagnosticsPromise: ReturnType | undefined; + const preserveSymlinks = build.initialOptions.preserveSymlinks; + + // Initialize a worker pool for JavaScript transformations. + // Webcontainers currently do not support this persistent cache store. + let cacheStore: PersistentCacheStore | undefined; + if (pluginOptions.sourceFileCache?.persistentCachePath && !process.versions.webcontainer) { + try { + cacheStore = await createPersistentCacheStore( + path.join(pluginOptions.sourceFileCache.persistentCachePath, 'angular-compiler'), + ); + } catch (e) { + setupWarnings.push({ + text: 'Unable to initialize JavaScript cache storage.', + location: null, + notes: [ + ...(e as Error).message + .split('\n') + .slice(1) + .map((text) => ({ text })), + { + text: 'This will not affect the build output content but may result in slower builds.', + }, + ], + }); + } + } + const javascriptTransformer = new JavaScriptTransformer( + { + sourcemap: !!pluginOptions.sourcemap, + thirdPartySourcemaps: pluginOptions.thirdPartySourcemaps, + advancedOptimizations: pluginOptions.advancedOptimizations, + jit: pluginOptions.jit || pluginOptions.includeTestMetadata, + maxConcurrency: maxTransformWorkers, + }, + cacheStore?.createCache('jstransformer'), + ); + + // Setup defines based on the values used by the Angular compiler-cli + build.initialOptions.define ??= {}; + build.initialOptions.define['ngI18nClosureMode'] ??= 'false'; + + // The factory is only relevant for compatibility purposes with the private API. + // TODO: Update private API in the next major to allow compilation function factory removal here. + const angularCompilationContext = + compilationContextOrCompilation instanceof AngularCompilationContext + ? compilationContextOrCompilation + : new PrimaryCompilationContext( + typeof compilationContextOrCompilation === 'function' + ? await compilationContextOrCompilation() + : compilationContextOrCompilation, + ); + const compilation: AngularCompilation | undefined = angularCompilationContext.compilation; + + // The in-memory cache of TypeScript file outputs will be used during the build in `onLoad` callbacks for TS files. + // A string value indicates direct TS/NG output and a Uint8Array indicates fully transformed code. + const typeScriptFileCache = + pluginOptions.sourceFileCache?.typeScriptFileCache ?? + new Map(); + + // The resources from component stylesheets and web workers that will be added to the build results output files + const additionalResults = new Map< + string, + { outputFiles?: OutputFile[]; metafile?: Metafile; errors?: PartialMessage[] } + >(); + + // Compilation is initially assumed to have errors until emitted + let hasCompilationErrors = true; + + // Determines if TypeScript should process JavaScript files based on tsconfig `allowJs` option + let shouldTsIgnoreJs = true; + // Determines if transpilation should be handle by TypeScript or esbuild + let useTypeScriptTranspilation = true; + + // To fully invalidate files, track resource referenced files and their referencing source + const referencedFileTracker = new FileReferenceTracker(); + + // eslint-disable-next-line max-lines-per-function + build.onStart(async () => { + hasCompilationErrors = true; + await initializeHash(); + + const result: OnStartResult = { + warnings: setupWarnings, + }; + + if (!angularCompilationContext.isPrimary()) { + hasCompilationErrors = await angularCompilationContext.waitUntilReady; + const compilerOptions = await angularCompilationContext.getCompilerOptions(); + shouldTsIgnoreJs = !compilerOptions.allowJs; + useTypeScriptTranspilation = !!compilerOptions['_useTypeScriptTranspilation']; + + return result; + } + + angularCompilationContext.markAsInProgress(); + const compilation = angularCompilationContext.compilation; + + // Reset debug performance tracking + resetCumulativeDurations(); + + // Update the reference tracker and generate a full set of modified files for the + // Angular compiler which does not have direct knowledge of transitive resource + // dependencies or web worker processing. + let modifiedFiles; + if (pluginOptions.sourceFileCache?.modifiedFiles.size) { + // TODO: Differentiate between changed input files and stale output files + modifiedFiles = referencedFileTracker.update(pluginOptions.sourceFileCache.modifiedFiles); + pluginOptions.sourceFileCache.invalidate(modifiedFiles); + // External runtime styles are invalidated and rebuilt at the beginning of a rebuild to avoid + // the need to execute the application bundler for component style only changes. + if (!pluginOptions.externalRuntimeStyles) { + stylesheetBundler.invalidate(modifiedFiles); + } + // Remove any stale additional results based on modified files + modifiedFiles.forEach((file) => additionalResults.delete(file)); + } + + if (compilation.update && pluginOptions.sourceFileCache?.modifiedFiles.size) { + await compilation.update(modifiedFiles ?? pluginOptions.sourceFileCache.modifiedFiles); + } + + // Create Angular compiler host options + const hostOptions: AngularHostOptions = { + fileReplacements: pluginOptions.fileReplacements, + modifiedFiles, + async transformStylesheet(data, containingFile, stylesheetFile, order, className) { + let stylesheetResult; + let resultSource = stylesheetFile ?? containingFile; + + // Stylesheet file only exists for external stylesheets + if (stylesheetFile) { + stylesheetResult = await stylesheetBundler.bundleFile(stylesheetFile); + } else { + stylesheetResult = await stylesheetBundler.bundleInline( + data, + containingFile, + // Inline stylesheets from a template style element are always CSS; Otherwise, use default. + containingFile.endsWith('.html') ? 'css' : undefined, + // When external runtime styles are enabled, an identifier for the style that does not change + // based on the content is required to avoid emitted JS code changes. Any JS code changes will + // invalid the output and force a full page reload for HMR cases. The containing file and order + // of the style within the containing file is used. + pluginOptions.externalRuntimeStyles + ? calculateHash(`${containingFile}${order ?? 0}${className ?? ''}`) + : undefined, + ); + // Adjust result source for inline styles. + // There may be multiple inline styles with the same containing file and to ensure that the results + // do not overwrite each other the result source identifier needs to be unique for each. The class + // name and order fields can be used for this. The structure is arbitrary as long as it is unique. + resultSource += `?class=${className}&order=${order}`; + } + + (result.warnings ??= []).push(...stylesheetResult.warnings); + if (stylesheetResult.errors) { + (result.errors ??= []).push(...stylesheetResult.errors); + + const { referencedFiles } = stylesheetResult; + if (referencedFiles) { + referencedFileTracker.add(containingFile, referencedFiles); + if (stylesheetFile) { + referencedFileTracker.add(stylesheetFile, referencedFiles); + } + } + + return ''; + } + + const { contents, outputFiles, metafile, referencedFiles } = stylesheetResult; + + additionalResults.set(resultSource, { + outputFiles, + metafile, + }); + + if (referencedFiles) { + referencedFileTracker.add(containingFile, referencedFiles); + if (stylesheetFile) { + // Angular AOT compiler needs modified direct resource files to correctly invalidate its analysis + referencedFileTracker.add(stylesheetFile, referencedFiles); + } + } + + return contents; + }, + processWebWorker(workerFile, containingFile) { + const fullWorkerPath = path.join(path.dirname(containingFile), workerFile); + // The synchronous API must be used due to the TypeScript compilation currently being + // fully synchronous and this process callback being called from within a TypeScript + // transformer. + const workerResult = bundleWebWorker(build, pluginOptions, fullWorkerPath); + + (result.warnings ??= []).push(...workerResult.warnings); + if (workerResult.errors.length > 0) { + (result.errors ??= []).push(...workerResult.errors); + // Track worker file errors to allow rebuilds on changes + referencedFileTracker.add( + containingFile, + workerResult.errors + .map((error) => error.location?.file) + .filter((file): file is string => !!file) + .map((file) => path.join(build.initialOptions.absWorkingDir ?? '', file)), + ); + additionalResults.set(fullWorkerPath, { errors: result.errors }); + + // Return the original path if the build failed + return workerFile; + } + + assert('outputFiles' in workerResult, 'Invalid web worker bundle result.'); + additionalResults.set(fullWorkerPath, { + outputFiles: workerResult.outputFiles, + metafile: workerResult.metafile, + }); + + referencedFileTracker.add( + containingFile, + Object.keys(workerResult.metafile.inputs).map((input) => + path.join(build.initialOptions.absWorkingDir ?? '', input), + ), + ); + + // Return bundled worker file entry name to be used in the built output + const workerCodeFile = workerResult.outputFiles.find((file) => + /^worker-[A-Z0-9]{8}.[cm]?js$/.test(path.basename(file.path)), + ); + assert(workerCodeFile, 'Web Worker bundled code file should always be present.'); + const workerCodePath = path.relative( + build.initialOptions.outdir ?? '', + workerCodeFile.path, + ); + + return workerCodePath.replaceAll('\\', '/'); + }, + }; + + // Initialize the Angular compilation for the current build. + // In watch mode, previous build state will be reused. + let referencedFiles; + let externalStylesheets; + try { + const initializationResult = await compilation.initialize( + pluginOptions.tsconfig, + hostOptions, + { + sourcemap: !!pluginOptions.sourcemap, + preserveSymlinks, + cachePath: pluginOptions.sourceFileCache?.persistentCachePath, + externalRuntimeStyles: pluginOptions.externalRuntimeStyles, + enableHmr: !!pluginOptions.templateUpdates, + instrumentForCoverage: !!pluginOptions.instrumentForCoverage, + includeTestMetadata: !!pluginOptions.includeTestMetadata, + customConditions: build.initialOptions.conditions, + rootFiles: pluginOptions.rootFiles, + basePath: build.initialOptions.absWorkingDir, + }, + ); + + if (initializationResult.warnings?.length) { + setupWarnings?.push(...initializationResult.warnings); + } + + angularCompilationContext.setCompilerOptions(initializationResult.compilerOptions); + shouldTsIgnoreJs = !initializationResult.compilerOptions.allowJs; + useTypeScriptTranspilation = + !!initializationResult.compilerOptions['_useTypeScriptTranspilation']; + referencedFiles = initializationResult.referencedFiles; + externalStylesheets = initializationResult.externalStylesheets; + + if (initializationResult.templateUpdates) { + // Propagate any template updates + initializationResult.templateUpdates.forEach((value, key) => + pluginOptions.templateUpdates?.set(key, value), + ); + } + } catch (error) { + (result.errors ??= []).push({ + text: 'Angular compilation initialization failed.', + location: null, + notes: [ + { + text: error instanceof Error ? (error.stack ?? error.message) : `${error}`, + location: null, + }, + ], + }); + + // Initialization failure prevents further compilation steps + hasCompilationErrors = true; + angularCompilationContext.markAsReady(true); + + return result; + } + + if (externalStylesheets) { + // Process any new external stylesheets + for (const [stylesheetFile, externalId] of externalStylesheets) { + await bundleExternalStylesheet( + stylesheetBundler, + stylesheetFile, + externalId, + result, + additionalResults, + ); + } + } + + // Update TypeScript file output cache for all affected files if not using on-demand transforms + if (!compilation.transformFile) { + try { + await profileAsync('NG_EMIT_TS', async () => { + for (const { filename, contents } of await compilation.emitAffectedFiles()) { + typeScriptFileCache.set(path.normalize(filename), contents); + } + }); + } catch (error) { + (result.errors ??= []).push({ + text: 'Angular compilation emit failed.', + location: null, + notes: [ + { + text: error instanceof Error ? (error.stack ?? error.message) : `${error}`, + location: null, + }, + ], + }); + } + + const diagnosticModes = useTypeChecking + ? DiagnosticModes.All + : DiagnosticModes.All & ~DiagnosticModes.Semantic; + diagnosticsPromise = compilation.diagnoseFiles(diagnosticModes).catch((error) => ({ + errors: [ + { + text: 'Angular compilation diagnostics failed.', + notes: [ + { + text: error instanceof Error ? (error.stack ?? error.message) : String(error), + }, + ], + }, + ], + })); + } + + // Add errors from failed additional results. + // This must be done after emit to capture latest web worker results. + for (const { errors } of additionalResults.values()) { + if (errors) { + (result.errors ??= []).push(...errors); + } + } + + // Store referenced files for updated file watching if enabled + if (pluginOptions.sourceFileCache) { + pluginOptions.sourceFileCache.referencedFiles = [ + ...referencedFiles, + ...referencedFileTracker.referencedFiles, + ]; + } + + hasCompilationErrors = !!result.errors?.length; + + // Reset the setup warnings so that they are only shown during the first build. + setupWarnings = undefined; + + angularCompilationContext.markAsReady(hasCompilationErrors); + + return result; + }); + + build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async (args) => { + const request = rewriteForBazel( + path.normalize(pluginOptions.fileReplacements?.[path.normalize(args.path)] ?? args.path), + ); + const isJS = /\.[cm]?js$/.test(request); + + // Skip TS load attempt if JS TypeScript compilation not enabled and file is JS + if (shouldTsIgnoreJs && isJS) { + return undefined; + } + + // The filename is currently used as a cache key. Since the cache is memory only, + // the options cannot change and do not need to be represented in the key. If the + // cache is later stored to disk, then the options that affect transform output + // would need to be added to the key as well as a check for any change of content. + let contents = typeScriptFileCache.get(request); + let directContents: string | undefined; + + if (contents === undefined && compilation?.transformFile) { + try { + directContents = await readFile(request, 'utf-8'); + const transformResult = await compilation.transformFile(request, directContents); + if (transformResult) { + contents = transformResult.contents; + if (transformResult.watchFiles) { + referencedFileTracker.add(request, transformResult.watchFiles); + } + } + } catch (error) { + return { + errors: [ + { + text: 'Angular compilation transform failed.', + location: { file: request }, + notes: [ + { + text: error instanceof Error ? (error.stack ?? error.message) : `${error}`, + location: null, + }, + ], + }, + ], + }; + } + } + + if (contents === undefined) { + // If the Angular compilation had errors the file may not have been emitted. + // To avoid additional errors about missing files, return empty contents. + if (hasCompilationErrors) { + return { contents: '', loader: 'js' }; + } + + // No TS result indicates the file is not part of the TypeScript program. + // If allowJs is enabled and the file is JS then defer to the next load hook. + if (!shouldTsIgnoreJs && isJS) { + return undefined; + } + + const diangosticRoot = build.initialOptions.absWorkingDir ?? ''; + + // Evaluate whether the file requires the Angular compiler transpilation. + // If not, issue a warning but allow bundler to process the file (no type-checking). + directContents ??= await readFile(request, 'utf-8'); + if (!requiresAngularCompiler(directContents)) { + return { + warnings: [createMissingFileDiagnostic(request, args.path, diangosticRoot, false)], + contents: directContents, + loader: 'ts', + resolveDir: path.dirname(request), + }; + } + + // Otherwise return an error + return { + errors: [createMissingFileDiagnostic(request, args.path, diangosticRoot, true)], + }; + } else if (typeof contents === 'string' && (useTypeScriptTranspilation || isJS)) { + // A string indicates untransformed output from the TS/NG compiler. + // This step is unneeded when using esbuild transpilation. + contents = await javascriptTransformer.transformData(request, contents, { + skipLinker: true, + sideEffects: () => hasSideEffects(request), + instrumentForCoverage: pluginOptions.instrumentForCoverage?.(request), + }); + + // Store as the returned Uint8Array to allow caching the fully transformed code + typeScriptFileCache.set(request, contents); + } + + let loader: Loader; + if (useTypeScriptTranspilation || isJS) { + // TypeScript has transpiled to JS or is already JS + loader = 'js'; + } else if (request.at(-1) === 'x') { + // TSX and TS have different syntax rules. Only set if input is a TSX file. + loader = 'tsx'; + } else { + // Otherwise, directly bundle TS + loader = 'ts'; + } + + return { + contents, + loader, + resolveDir: path.dirname(request), + }; + }); + + build.onLoad( + { filter: /\.[cm]?js$/ }, + createCachedLoad(pluginOptions.loadResultCache, async (args) => { + let request = rewriteForBazel(args.path); + if (pluginOptions.fileReplacements) { + const replacement = pluginOptions.fileReplacements[path.normalize(args.path)]; + if (replacement) { + request = path.normalize(replacement); + } + } + + return profileAsync( + 'NG_EMIT_JS*', + async () => { + const contents = await javascriptTransformer.transformFile(request, { + skipLinker: pluginOptions.jit, + sideEffects: () => hasSideEffects(request), + }); + + return { + contents, + loader: 'js', + resolveDir: path.dirname(request), + watchFiles: request !== args.path ? [request] : undefined, + }; + }, + true, + ); + }), + ); + + // Add a load handler if there are file replacement option entries for JSON files + if ( + pluginOptions.fileReplacements && + Object.keys(pluginOptions.fileReplacements).some((value) => value.endsWith('.json')) + ) { + build.onLoad( + { filter: /\.json$/ }, + createCachedLoad(pluginOptions.loadResultCache, async (args) => { + const replacement = pluginOptions.fileReplacements?.[path.normalize(args.path)]; + if (replacement) { + return { + contents: await readFile(path.normalize(replacement)), + loader: 'json' as const, + watchFiles: [replacement], + }; + } + + // If no replacement defined, let esbuild handle it directly + return null; + }), + ); + } + + // Setup bundling of component templates and stylesheets when in JIT mode + if (pluginOptions.jit) { + setupJitPluginCallbacks( + build, + stylesheetBundler, + additionalResults, + pluginOptions.loadResultCache, + ); + } + + build.onEnd(async (result) => { + // Ensure other compilations are unblocked if the main compilation throws during start + if (angularCompilationContext.isPrimary()) { + angularCompilationContext.markAsReady(hasCompilationErrors); + } + + for (const { outputFiles, metafile } of additionalResults.values()) { + // Add any additional output files to the main output files + if (outputFiles?.length) { + result.outputFiles?.push(...outputFiles); + } + + // Combine additional metafiles with main metafile + if (result.metafile && metafile) { + // Append the existing object, by appending to it we prevent unnecessary new objections creations with spread + // mitigating significant performance overhead for large apps. + // See: https://bugs.chromium.org/p/v8/issues/detail?id=11536 + Object.assign(result.metafile.inputs, metafile.inputs); + for (const [outputPath, output] of Object.entries(metafile.outputs)) { + // Two stylesheets can bundle to the same output file. Clone the inputs on first + // insert so colliding inputs can be unioned in place with Object.assign, keeping + // every input attributed without per-collision spread allocations and leaving the + // cached `additionalResults` metafile untouched. + const existing = result.metafile.outputs[outputPath]; + if (existing) { + Object.assign(existing.inputs, output.inputs); + } else { + result.metafile.outputs[outputPath] = { + ...output, + inputs: { ...output.inputs }, + }; + } + } + } + } + + logCumulativeDurations(); + + if (diagnosticsPromise) { + try { + const diagnostics = await diagnosticsPromise; + const errors = diagnostics.errors?.length ? diagnostics.errors : undefined; + const warnings = diagnostics.warnings?.length ? diagnostics.warnings : undefined; + + if (errors || warnings) { + return { errors, warnings }; + } + } finally { + diagnosticsPromise = undefined; + } + } + }); + + build.onDispose(() => { + void angularCompilationContext.dispose(); + void javascriptTransformer.close(); + void cacheStore?.close(); + }); + + /** + * Checks if the file has side-effects when `advancedOptimizations` is enabled. + */ + async function hasSideEffects(path: string): Promise { + if (!pluginOptions.advancedOptimizations) { + return undefined; + } + + const { sideEffects } = await build.resolve(path, { + kind: 'import-statement', + resolveDir: build.initialOptions.absWorkingDir ?? '', + }); + + return sideEffects; + } + }, + }; +} + +async function bundleExternalStylesheet( + stylesheetBundler: ComponentStylesheetBundler, + stylesheetFile: string, + externalId: string | boolean, + result: OnStartResult, + additionalResults: Map< + string, + { outputFiles?: OutputFile[]; metafile?: Metafile; errors?: PartialMessage[] } + >, +) { + const styleResult = await stylesheetBundler.bundleFile(stylesheetFile, externalId); + + (result.warnings ??= []).push(...styleResult.warnings); + if (styleResult.errors) { + (result.errors ??= []).push(...styleResult.errors); + } else { + const { outputFiles, metafile } = styleResult; + // Clear inputs to prevent triggering a rebuild of the application code for component + // stylesheet file only changes when the dev server enables the internal-only external + // stylesheet option. This does not affect builds since only the dev server can enable + // the internal option. + metafile.inputs = {}; + additionalResults.set(stylesheetFile, { + outputFiles, + metafile, + }); + } +} + +function bundleWebWorker( + build: PluginBuild, + pluginOptions: CompilerPluginOptions, + workerFile: string, +) { + try { + return build.esbuild.buildSync({ + ...build.initialOptions, + platform: 'browser', + write: false, + bundle: true, + metafile: true, + format: 'esm', + entryNames: 'worker-[hash]', + chunkNames: 'worker-chunk-[hash]', + entryPoints: [workerFile], + sourcemap: pluginOptions.sourcemap, + // Zone.js is not used in Web workers so no need to disable + supported: undefined, + // Plugins are not supported in sync esbuild calls + plugins: undefined, + }); + } catch (error) { + if (error && typeof error === 'object' && 'errors' in error && 'warnings' in error) { + return error as BuildFailure; + } + throw error; + } +} + +function createMissingFileDiagnostic( + request: string, + original: string, + root: string, + angular: boolean, +): PartialMessage { + const relativeRequest = path.relative(root, request); + const notes: PartialNote[] = []; + + if (angular) { + notes.push({ + text: + `Files containing Angular metadata ('@Component'/'@Directive'/etc.) must be part of the TypeScript compilation.` + + ` You can ensure the file is part of the TypeScript program via the 'files' or 'include' property.`, + }); + } else { + notes.push({ + text: + `The file will be bundled and included in the output but will not be type-checked at build time.` + + ` To remove this message you can add the file to the TypeScript program via the 'files' or 'include' property.`, + }); + } + + const relativeOriginal = path.relative(root, original); + if (relativeRequest !== relativeOriginal) { + notes.push({ + text: `File is requested from a file replacement of '${relativeOriginal}'.`, + }); + } + + const diagnostic = { + text: `File '${relativeRequest}' not found in TypeScript compilation.`, + notes, + }; + + return diagnostic; +} + +const POTENTIAL_METADATA_REGEX = /@angular\/core|@Component|@Directive|@Injectable|@Pipe|@NgModule/; + +function requiresAngularCompiler(contents: string): boolean { + return POTENTIAL_METADATA_REGEX.test(contents); +} diff --git a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts new file mode 100644 index 000000000000..f8b048ce5459 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts @@ -0,0 +1,338 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import path from 'node:path'; +import { MemoryCache } from '../../../utils/cache'; +import { createContentHash } from '../../../utils/hash'; +import { BundleContextResult, BundlerContext } from '../bundler-context'; +import { type BuildOutputFile, BuildOutputFileType } from '../bundler-files'; +import { MemoryLoadResultCache } from '../load-result-cache'; +import { + BundleStylesheetOptions, + createStylesheetBundleOptions, +} from '../stylesheets/bundle-options'; + +export type ComponentStylesheetResult = BundleContextResult & { + contents: string; + referencedFiles: Set | undefined; +}; + +/** + * Bundles component stylesheets. A stylesheet can be either an inline stylesheet that + * is contained within the Component's metadata definition or an external file referenced + * from the Component's metadata definition. + */ +export class ComponentStylesheetBundler { + readonly #fileContexts = new MemoryCache(); + readonly #inlineContexts = new MemoryCache(); + readonly #loadCache = new MemoryLoadResultCache(); + + /** + * @param options An object containing the stylesheet bundling options. + * @param defaultInlineLanguage The default language to use for inline component styles. + * @param incremental True if incremental watch mode is enabled. + */ + constructor( + private readonly options: BundleStylesheetOptions, + private readonly defaultInlineLanguage: string, + private readonly incremental: boolean, + ) {} + + /** + * Bundle a file-based component stylesheet for use within an AOT compiled Angular application. + * @param entry The file path of the stylesheet. + * @param externalId Either an external identifier string for initial bundling or a boolean for rebuilds, if external. + * @param direct If true, the output will be used directly by the builder; false if used inside the compiler plugin. + * @returns A component bundle result object. + */ + async bundleFile( + entry: string, + externalId?: string | boolean, + direct?: boolean, + ): Promise { + entry = path.normalize(entry); + + const bundlerContext = await this.#fileContexts.getOrCreate(entry, () => { + return new BundlerContext( + this.options.workspaceRoot, + this.incremental, + (loadCache) => { + const buildOptions = createStylesheetBundleOptions(this.options, loadCache); + if (externalId) { + assert( + typeof externalId === 'string', + 'Initial external component stylesheets must have a string identifier', + ); + + buildOptions.entryPoints = { [externalId]: entry }; + buildOptions.entryNames = '[name]'; + delete buildOptions.publicPath; + } else { + buildOptions.entryPoints = [entry]; + } + + // Angular encapsulation does not support nesting + // See: https://github.com/angular/angular/issues/58996 + buildOptions.supported ??= {}; + buildOptions.supported['nesting'] = false; + + return buildOptions; + }, + /* useContext */ false, + /* initialFilter */ undefined, + this.#loadCache, + ); + }); + + return this.extractResult( + await bundlerContext.bundle(), + bundlerContext.watchFiles, + !!externalId, + !!direct, + ); + } + + bundleAllFiles(external: boolean, direct: boolean) { + return Promise.all( + Array.from(this.#fileContexts.entries()).map(([entry]) => + this.bundleFile(entry, external, direct), + ), + ); + } + + async bundleInline( + data: string, + filename: string, + language = this.defaultInlineLanguage, + externalId?: string, + ): Promise { + filename = path.normalize(filename); + + // Use a hash of the inline stylesheet content to ensure a consistent identifier. External stylesheets will resolve + // to the actual stylesheet file path. + const hasher = createContentHash(); + hasher.update(data); + hasher.update(externalId ?? ''); + const id = hasher.digest(); + const entry = [language, id, filename].join(';'); + + const bundlerContext = await this.#inlineContexts.getOrCreate(entry, () => { + const namespace = 'angular:styles/component'; + + return new BundlerContext( + this.options.workspaceRoot, + this.incremental, + (loadCache) => { + const buildOptions = createStylesheetBundleOptions(this.options, loadCache, { + [entry]: data, + }); + if (externalId) { + buildOptions.entryPoints = { [externalId]: `${namespace};${entry}` }; + buildOptions.entryNames = '[name]'; + delete buildOptions.publicPath; + } else { + buildOptions.entryPoints = [`${namespace};${entry}`]; + } + + // Angular encapsulation does not support nesting + // See: https://github.com/angular/angular/issues/58996 + buildOptions.supported ??= {}; + buildOptions.supported['nesting'] = false; + + buildOptions.plugins.push({ + name: 'angular-component-styles', + setup(build) { + build.onResolve({ filter: /^angular:styles\/component;/ }, (args) => { + if (args.kind !== 'entry-point') { + return null; + } + + return { + path: entry, + namespace, + }; + }); + build.onLoad({ filter: /^css;/, namespace }, () => { + return { + contents: data, + loader: 'css', + resolveDir: path.dirname(filename), + }; + }); + }, + }); + + return buildOptions; + }, + /* useContext */ false, + /* initialFilter */ undefined, + this.#loadCache, + ); + }); + + const result = await bundlerContext.bundle(); + if (result.errors || result.warnings.length > 0) { + if (this.incremental && result.errors) { + bundlerContext.watchFiles.add( + path.isAbsolute(filename) ? filename : path.join(this.options.workspaceRoot, filename), + ); + } + for (const message of [...(result.errors ?? []), ...result.warnings]) { + for (const item of [message, ...(message.notes ?? [])]) { + if (item.location?.file?.startsWith('angular:styles/component:')) { + item.location.file = filename; + } + } + } + } + + // Extract the result of the bundling from the output files + return this.extractResult(result, bundlerContext.watchFiles, !!externalId, false); + } + + /** + * Invalidates both file and inline based component style bundling state for a set of modified files. + * @param files The group of files that have been modified + * @returns An array of file based stylesheet entries if any were invalidated; otherwise, undefined. + */ + invalidate(files: Iterable | ReadonlySet): string[] | undefined { + if (!this.incremental) { + return; + } + + const normalizedFiles = new Set(); + for (const file of files) { + const normalized = path.normalize(file); + normalizedFiles.add(normalized); + if (!path.isAbsolute(normalized)) { + normalizedFiles.add(path.normalize(path.join(this.options.workspaceRoot, normalized))); + } + } + + let entries: string[] | undefined; + + for (const [entry, bundler] of this.#fileContexts.entries()) { + if (bundler.invalidate(normalizedFiles)) { + entries ??= []; + entries.push(entry); + } + } + for (const [entry, bundler] of this.#inlineContexts.entries()) { + // Entry is format: [language, id, filename].join(';') + const firstSemi = entry.indexOf(';'); + const secondSemi = firstSemi !== -1 ? entry.indexOf(';', firstSemi + 1) : -1; + const filename = secondSemi !== -1 ? entry.slice(secondSemi + 1) : ''; + if (filename && normalizedFiles.has(path.normalize(filename))) { + this.#inlineContexts.delete(entry); + void bundler.dispose().catch(() => {}); + } else { + bundler.invalidate(normalizedFiles); + } + } + + return entries; + } + + collectReferencedFiles(): string[] { + const files: string[] = []; + for (const context of this.#fileContexts.values()) { + files.push(...context.watchFiles); + } + for (const context of this.#inlineContexts.values()) { + files.push(...context.watchFiles); + } + + return files; + } + + async dispose(): Promise { + const contexts = [...this.#fileContexts.values(), ...this.#inlineContexts.values()]; + this.#fileContexts.clear(); + this.#inlineContexts.clear(); + this.#loadCache.clear(); + + await Promise.allSettled(contexts.map((context) => context.dispose())); + } + + private extractResult( + result: BundleContextResult, + referencedFiles: Set | undefined, + external: boolean, + direct: boolean, + ): ComponentStylesheetResult { + let contents = ''; + const outputFiles: BuildOutputFile[] = []; + + const { errors, warnings } = result; + if (errors) { + return { errors, warnings, referencedFiles, contents: '' }; + } + + for (const outputFile of result.outputFiles) { + const filename = path.basename(outputFile.path); + + if (outputFile.type === BuildOutputFileType.Media || filename.endsWith('.css.map')) { + // The output files could also contain resources (images/fonts/etc.) that were referenced and the map files. + + // Clone the output file to avoid amending the original path which would causes problems during rebuild. + const clonedOutputFile = outputFile.clone(); + + // Needed for Bazel as otherwise the files will not be written in the correct place, + // this is because esbuild will resolve the output file from the outdir which is currently set to `workspaceRoot` twice, + // once in the stylesheet and the other in the application code bundler. + // Ex: `../../../../../app.component.css.map`. + if (!direct) { + clonedOutputFile.path = path.join(this.options.workspaceRoot, outputFile.path); + } + + outputFiles.push(clonedOutputFile); + } else if (filename.endsWith('.css')) { + if (external) { + const clonedOutputFile = outputFile.clone(); + if (!direct) { + clonedOutputFile.path = path.join(this.options.workspaceRoot, outputFile.path); + } + outputFiles.push(clonedOutputFile); + contents = path.posix.join(this.options.publicPath ?? '', filename); + } else { + contents = outputFile.text; + } + } else { + throw new Error( + `Unexpected non CSS/Media file "${filename}" outputted during component stylesheet processing.`, + ); + } + } + + // Clone metafile to prevent mutation of the cached result by downstream plugins + const metafile = { + inputs: { ...result.metafile.inputs }, + outputs: Object.fromEntries( + Object.entries(result.metafile.outputs).map(([key, output]) => { + const cloned = { ...output, ['ng-component']: true }; + delete cloned.entryPoint; + + return [key, cloned]; + }), + ), + }; + + return { + errors, + warnings, + contents, + outputFiles, + metafile, + referencedFiles, + externalImports: result.externalImports, + platform: result.platform, + initialFiles: new Map(), + }; + } +} diff --git a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets_spec.ts b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets_spec.ts new file mode 100644 index 000000000000..0363f2f43ba9 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets_spec.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { initializeHash } from '../../../utils/hash'; +import { ComponentStylesheetBundler } from './component-stylesheets'; + +describe('ComponentStylesheetBundler', () => { + let tempRoot: string; + + beforeAll(async () => { + await initializeHash(); + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-component-stylesheets-')); + }); + + afterAll(async () => { + await rm(tempRoot, { force: true, recursive: true }); + }); + + it('uses the component filename instead of angular:styles/component virtual URI in bundleInline error locations', async () => { + const bundler = new ComponentStylesheetBundler( + { + workspaceRoot: tempRoot, + optimization: false, + inlineFonts: false, + sourcemap: false, + outputNames: { bundles: '[name]', media: 'media/[name]' }, + target: ['es2022'], + cacheOptions: { enabled: false, path: '', basePath: tempRoot }, + }, + 'css', + true, + ); + + const componentFile = join(tempRoot, 'src', 'lib', 'lib.component.ts'); + + try { + const result = await bundler.bundleInline( + '.icon { background: url("./missing.png"); }', + componentFile, + ); + + assert(result.errors, 'Expected bundleInline to report a resolution error'); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0].location?.file).toBe(componentFile); + expect(result.referencedFiles).toContain(componentFile); + } finally { + await bundler.dispose(); + } + }); + + it('uses the component filename instead of angular:styles/component virtual URI in bundleInline warning locations', async () => { + const bundler = new ComponentStylesheetBundler( + { + workspaceRoot: tempRoot, + optimization: false, + inlineFonts: false, + sourcemap: false, + outputNames: { bundles: '[name]', media: 'media/[name]' }, + target: ['es2022'], + cacheOptions: { enabled: false, path: '', basePath: tempRoot }, + }, + 'css', + false, + ); + + const componentFile = join(tempRoot, 'src', 'lib', 'lib.component.ts'); + + try { + const result = await bundler.bundleInline('.box { color: red', componentFile); + + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0].location?.file).toBe(componentFile); + } finally { + await bundler.dispose(); + } + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/angular/file-reference-tracker.ts b/packages/angular/build/src/tools/esbuild/angular/file-reference-tracker.ts new file mode 100644 index 000000000000..7d420679e67a --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/file-reference-tracker.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { normalize } from 'node:path'; + +export class FileReferenceTracker { + #referencingFiles = new Map>(); + + get referencedFiles() { + return this.#referencingFiles.keys(); + } + + add(containingFile: string, referencedFiles: Iterable): void { + const normalizedContainingFile = normalize(containingFile); + for (const file of referencedFiles) { + const normalizedReferencedFile = normalize(file); + if (normalizedReferencedFile === normalizedContainingFile) { + // Containing file is already known to the AOT compiler + continue; + } + + const referencing = this.#referencingFiles.get(normalizedReferencedFile); + if (referencing === undefined) { + this.#referencingFiles.set(normalizedReferencedFile, new Set([normalizedContainingFile])); + } else { + referencing.add(normalizedContainingFile); + } + } + } + + /** + * + * @param changed The set of changed files. + */ + update(changed: Set): Set { + // Lazily initialized to avoid unneeded copying if there are no additions to return + let allChangedFiles: Set | undefined; + + // Add referencing files to fully notify the AOT compiler of required component updates + for (const modifiedFile of changed) { + const normalizedModifiedFile = normalize(modifiedFile); + const referencing = this.#referencingFiles.get(normalizedModifiedFile); + if (referencing) { + allChangedFiles ??= new Set(changed); + for (const referencingFile of referencing) { + allChangedFiles.add(referencingFile); + } + // Cleanup the stale record which will be updated by new resource transforms + this.#referencingFiles.delete(normalizedModifiedFile); + } + } + + return allChangedFiles ?? changed; + } +} diff --git a/packages/angular/build/src/tools/esbuild/angular/jit-plugin-callbacks.ts b/packages/angular/build/src/tools/esbuild/angular/jit-plugin-callbacks.ts new file mode 100644 index 000000000000..6b99b92dd1fa --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/jit-plugin-callbacks.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Metafile, OutputFile, PluginBuild } from 'esbuild'; +import { readFile } from 'node:fs/promises'; +import { dirname, join, relative } from 'node:path'; +import { + JIT_NAMESPACE_REGEXP, + JIT_STYLE_NAMESPACE, + JIT_TEMPLATE_NAMESPACE, + parseJitUri, +} from '../../angular/uri'; +import { LoadResultCache, createCachedLoad } from '../load-result-cache'; +import { ComponentStylesheetBundler } from './component-stylesheets'; + +/** + * Loads/extracts the contents from a load callback Angular JIT entry. + * An Angular JIT entry represents either a file path for a component resource or base64 + * encoded data for an inline component resource. + * @param entry The value that represents content to load. + * @param root The absolute path for the root of the build (typically the workspace root). + * @param skipRead If true, do not attempt to read the file; if false, read file content from disk. + * This option has no effect if the entry does not originate from a file. Defaults to false. + * @returns An object containing the absolute path of the contents and optionally the actual contents. + * For inline entries the contents will always be provided. + */ +async function loadEntry( + entry: string, + root: string, + skipRead?: boolean, +): Promise<{ path: string; contents?: string }> { + if (entry.startsWith('inline:')) { + const [importer, data] = entry.slice(7).split(';', 2); + + return { + path: join(root, importer), + contents: Buffer.from(data, 'base64').toString(), + }; + } + + const path = join(root, entry); + + return { + path, + contents: skipRead ? undefined : await readFile(path, 'utf-8'), + }; +} + +/** + * Sets up esbuild resolve and load callbacks to support Angular JIT mode processing + * for both Component stylesheets and templates. These callbacks work alongside the JIT + * resource TypeScript transformer to convert and then bundle Component resources as + * static imports. + * @param build An esbuild {@link PluginBuild} instance used to add callbacks. + * @param styleOptions The options to use when bundling stylesheets. + * @param additionalResultFiles A Map where stylesheet resources will be added. + */ +export function setupJitPluginCallbacks( + build: PluginBuild, + stylesheetBundler: ComponentStylesheetBundler, + additionalResultFiles: Map, + loadCache?: LoadResultCache, +): void { + const root = build.initialOptions.absWorkingDir ?? ''; + + // Add a resolve callback to capture and parse any JIT URIs that were added by the + // JIT resource TypeScript transformer. + // Resources originating from a file are resolved as relative from the containing file (importer). + build.onResolve({ filter: JIT_NAMESPACE_REGEXP }, (args) => { + const parsed = parseJitUri(args.path); + if (!parsed) { + return undefined; + } + + const { namespace, origin, specifier } = parsed; + + if (origin === 'file') { + return { + // Use a relative path to prevent fully resolved paths in the metafile (JSON stats file). + // This is only necessary for custom namespaces. esbuild will handle the file namespace. + path: relative(root, join(dirname(args.importer), specifier)), + namespace, + }; + } else { + // Inline data may need the importer to resolve imports/references within the content + const importer = relative(root, args.importer); + + return { + path: `inline:${importer};${specifier}`, + namespace, + }; + } + }); + + // Add a load callback to handle Component stylesheets (both inline and external) + build.onLoad( + { filter: /./, namespace: JIT_STYLE_NAMESPACE }, + createCachedLoad(loadCache, async (args) => { + // skipRead is used here because the stylesheet bundling will read a file stylesheet + // directly either via a preprocessor or esbuild itself. + const entry = await loadEntry(args.path, root, true /* skipRead */); + + let stylesheetResult; + + // Stylesheet contents only exist for internal stylesheets + if (entry.contents === undefined) { + stylesheetResult = await stylesheetBundler.bundleFile(entry.path); + } else { + stylesheetResult = await stylesheetBundler.bundleInline(entry.contents, entry.path); + } + + const { errors, warnings, referencedFiles } = stylesheetResult; + if (stylesheetResult.errors) { + return { + errors, + warnings, + watchFiles: referencedFiles && [...referencedFiles], + }; + } + + const { contents, outputFiles, metafile } = stylesheetResult; + + additionalResultFiles.set(entry.path, { outputFiles, metafile }); + + return { + errors, + warnings, + contents, + loader: 'text', + watchFiles: referencedFiles && [...referencedFiles], + }; + }), + ); + + // Add a load callback to handle Component templates + // NOTE: While this callback supports both inline and external templates, the transformer + // currently only supports generating URIs for external templates. + build.onLoad( + { filter: /./, namespace: JIT_TEMPLATE_NAMESPACE }, + createCachedLoad(loadCache, async (args) => { + const { contents, path } = await loadEntry(args.path, root); + + return { + contents, + loader: 'text', + watchFiles: [path], + }; + }), + ); +} diff --git a/packages/angular/build/src/tools/esbuild/angular/rewrite-bazel-paths.ts b/packages/angular/build/src/tools/esbuild/angular/rewrite-bazel-paths.ts new file mode 100644 index 000000000000..b7cd376d560e --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/rewrite-bazel-paths.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { join, relative, sep } from 'node:path'; + +const bazelBinDirectory = process.env['BAZEL_BINDIR']; +const bazelExecRoot = process.env['JS_BINARY__EXECROOT']; +const execRootMarker = `${sep}execroot${sep}`; + +/** + * Sandboxed actions run in a copy of the execroot whose absolute path differs from + * `JS_BINARY__EXECROOT`, so the effective execroot is derived from the working directory. + */ +function effectiveExecRoot(): string | undefined { + const cwd = process.cwd(); + const markerIndex = cwd.indexOf(execRootMarker); + if (markerIndex === -1) { + return bazelExecRoot; + } + + const workspaceEnd = cwd.indexOf(sep, markerIndex + execRootMarker.length); + + return workspaceEnd === -1 ? cwd : cwd.slice(0, workspaceEnd); +} + +export function rewriteForBazel(path: string): string { + if (!bazelBinDirectory || !bazelExecRoot) { + return path; + } + + const execRoot = effectiveExecRoot() ?? bazelExecRoot; + + const fromExecRoot = relative(execRoot, path); + if (!fromExecRoot.startsWith('..')) { + return path; + } + + const fromBinDirectory = relative(bazelBinDirectory, path); + if (!fromBinDirectory.startsWith('..')) { + return join(execRoot, fromBinDirectory); + } + + // A path that realpath resolved out of a symlink-staged sandbox into the + // unsandboxed execroot: re-anchor its execroot-relative tail. + const markerIndex = path.indexOf(execRootMarker); + if (markerIndex !== -1) { + const tail = path.slice(markerIndex + execRootMarker.length); + const workspaceEnd = tail.indexOf(sep); + if (workspaceEnd !== -1) { + return join(execRoot, tail.slice(workspaceEnd + 1)); + } + } + + return path; +} diff --git a/packages/angular/build/src/tools/esbuild/angular/source-file-cache.ts b/packages/angular/build/src/tools/esbuild/angular/source-file-cache.ts new file mode 100644 index 000000000000..136fbb651ba2 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/source-file-cache.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import * as path from 'node:path'; +import { MemoryLoadResultCache } from '../load-result-cache'; + +export class SourceFileCache { + readonly modifiedFiles = new Set(); + readonly typeScriptFileCache = new Map(); + readonly loadResultCache = new MemoryLoadResultCache(); + + referencedFiles?: readonly string[]; + + constructor(readonly persistentCachePath?: string) {} + + /** + * Releases all cached content. The cached data is only needed for incremental + * rebuilds and can include the emitted contents of every TypeScript file in the + * program. The cache is repopulated if a build is performed after this is called. + */ + clear(): void { + this.modifiedFiles.clear(); + this.typeScriptFileCache.clear(); + this.loadResultCache.clear(); + this.referencedFiles = undefined; + } + + invalidate(files: Iterable): boolean { + if (files !== this.modifiedFiles) { + this.modifiedFiles.clear(); + } + + const extraWatchFiles = new Set(this.referencedFiles?.map(path.normalize)); + + let invalid = false; + for (let file of files) { + file = path.normalize(file); + invalid = this.loadResultCache.invalidate(file) || invalid; + invalid = extraWatchFiles.has(file) || invalid; + this.modifiedFiles.add(file); + } + + return invalid; + } +} diff --git a/packages/angular/build/src/tools/esbuild/angular/source-file-cache_spec.ts b/packages/angular/build/src/tools/esbuild/angular/source-file-cache_spec.ts new file mode 100644 index 000000000000..1a3b2c402618 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/source-file-cache_spec.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { SourceFileCache } from './source-file-cache'; + +describe('SourceFileCache', () => { + let cache: SourceFileCache; + + beforeEach(() => { + cache = new SourceFileCache(); + }); + + it('should clean up all caches on clear()', async () => { + cache.typeScriptFileCache.set('/test/app.component.ts', 'console.log("test");'); + cache.modifiedFiles.add('/test/app.component.ts'); + cache.referencedFiles = ['/test/app.component.ts']; + await cache.loadResultCache.put('file:/test/app.component.ts', { + contents: 'test', + loader: 'js', + }); + + cache.clear(); + + expect(cache.typeScriptFileCache.size).toBe(0); + expect(cache.modifiedFiles.size).toBe(0); + expect(cache.referencedFiles).toBeUndefined(); + expect(cache.loadResultCache.get('file:/test/app.component.ts')).toBeUndefined(); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts new file mode 100644 index 000000000000..6cd2b09f5a67 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts @@ -0,0 +1,790 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { BuildOptions, Plugin } from 'esbuild'; +import assert from 'node:assert'; +import { extname, relative } from 'node:path'; +import type { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import { Platform } from '../../builders/application/schema'; +import { allowMangle } from '../../utils/environment-options'; +import { calculateHash } from '../../utils/hash'; +import { toPosixPath } from '../../utils/path'; +import { + SERVER_APP_ENGINE_MANIFEST_FILENAME, + SERVER_APP_MANIFEST_FILENAME, + SERVER_GENERATED_EXTERNALS, +} from '../../utils/server-rendering/manifest'; +import { AngularCompilationContext } from './angular/compilation-state'; +import { createCompilerPlugin } from './angular/compiler-plugin'; +import { ComponentStylesheetBundler } from './angular/component-stylesheets'; +import { SourceFileCache } from './angular/source-file-cache'; +import { createAngularLocalizeInitWarningPlugin } from './angular-localize-init-warning-plugin'; +import { BundlerOptionsFactory } from './bundler-context'; +import { createCompilerPluginOptions } from './compiler-plugin-options'; +import { createExternalPackagesPlugin } from './external-packages-plugin'; +import { createAngularLocaleDataPlugin } from './i18n-locale-plugin'; +import type { LoadResultCache } from './load-result-cache'; +import { createLoaderImportAttributePlugin } from './loader-import-attribute-plugin'; +import { + createPlatformServerDominoExposePlugin, + createPlatformServerDominoRedirectPlugin, +} from './platform-server-domino-plugin'; +import { createRxjsEsmResolutionPlugin } from './rxjs-esm-resolution-plugin'; +import { createServerBundleMetadata } from './server-bundle-metadata-plugin'; +import { createSourcemapIgnorelistPlugin } from './sourcemap-ignorelist-plugin'; +import { getFeatureSupport, isZonelessApp } from './utils'; +import { createVirtualModulePlugin } from './virtual-module-plugin'; +import { createWasmPlugin } from './wasm-plugin'; + +export function createBrowserCodeBundleOptions( + options: NormalizedApplicationBuildOptions, + target: string[], + sourceFileCache: SourceFileCache, + stylesheetBundler: ComponentStylesheetBundler, + angularCompilationContext: AngularCompilationContext, + templateUpdates: Map | undefined, +): BundlerOptionsFactory { + return (loadCache) => { + const { entryPoints, outputNames, polyfills } = options; + const zoneless = isZonelessApp(polyfills); + + const pluginOptions = createCompilerPluginOptions( + options, + sourceFileCache, + loadCache, + templateUpdates, + ); + + const buildOptions: BuildOptions = { + ...getEsBuildCommonOptions(options), + platform: 'browser', + // Note: `es2015` is needed for RxJS v6. If not specified, `module` would + // match and the ES5 distribution would be bundled and ends up breaking at + // runtime with the RxJS testing library. + // More details: https://github.com/angular/angular-cli/issues/25405. + mainFields: ['es2020', 'es2015', 'browser', 'module', 'main'], + entryNames: outputNames.bundles, + entryPoints, + target, + supported: getFeatureSupport(zoneless), + }; + + if (options.disableCodeSplitting) { + // Splitting emits shared chunks that are read across chunk boundaries as live ESM bindings, + // which the unit-test runners' module loading does not reliably preserve. + buildOptions.splitting = false; + // In unit test builds, package.json "sideEffects": false annotations can cause esbuild + // to incorrectly elide statically-referenced barrel module bodies across multiple entry points. + buildOptions.ignoreAnnotations = true; + } + + buildOptions.plugins ??= []; + buildOptions.plugins.push( + createWasmPlugin({ allowAsync: zoneless, cache: loadCache }), + createAngularLocalizeInitWarningPlugin(), + createCompilerPlugin( + // JS/TS options + pluginOptions, + angularCompilationContext, + // Component stylesheet bundler + stylesheetBundler, + ), + ); + + if (options.plugins) { + buildOptions.plugins.push(...options.plugins); + } + + return buildOptions; + }; +} + +export function createBrowserPolyfillBundleOptions( + options: NormalizedApplicationBuildOptions, + target: string[], + sourceFileCache: SourceFileCache, + stylesheetBundler: ComponentStylesheetBundler, + angularCompilationContext: AngularCompilationContext, +): BuildOptions | BundlerOptionsFactory | undefined { + const namespace = 'angular:polyfills'; + const polyfillBundleOptions = getEsBuildCommonPolyfillsOptions( + options, + namespace, + true, + sourceFileCache.loadResultCache, + ); + if (!polyfillBundleOptions) { + return; + } + + const { outputNames, polyfills } = options; + const hasTypeScriptEntries = polyfills?.some((entry) => /\.[cm]?tsx?$/.test(entry)); + + const buildOptions: BuildOptions = { + ...polyfillBundleOptions, + platform: 'browser', + // Note: `es2015` is needed for RxJS v6. If not specified, `module` would + // match and the ES5 distribution would be bundled and ends up breaking at + // runtime with the RxJS testing library. + // More details: https://github.com/angular/angular-cli/issues/25405. + mainFields: ['es2020', 'es2015', 'browser', 'module', 'main'], + entryNames: outputNames.bundles, + target, + entryPoints: { + 'polyfills': namespace, + }, + }; + + // Only add the Angular TypeScript compiler if TypeScript files are provided in the polyfills + if (hasTypeScriptEntries) { + buildOptions.plugins ??= []; + const pluginOptions = createCompilerPluginOptions( + options, + sourceFileCache, + sourceFileCache.loadResultCache, + ); + buildOptions.plugins.push( + createCompilerPlugin( + // JS/TS options + pluginOptions, + angularCompilationContext, + // Component stylesheet options are unused for polyfills but required by the plugin + stylesheetBundler, + ), + ); + } + + // Use an options factory to allow fully incremental bundling when no TypeScript files are present. + // The TypeScript compilation is not currently integrated into the bundler invalidation so + // cannot be used with fully incremental bundling yet. + return hasTypeScriptEntries ? buildOptions : () => buildOptions; +} + +export function createServerPolyfillBundleOptions( + options: NormalizedApplicationBuildOptions, + target: string[], + loadResultCache: LoadResultCache | undefined, +): BundlerOptionsFactory | undefined { + const serverPolyfills: string[] = []; + const polyfillsFromConfig = new Set(options.polyfills); + const isNodePlatform = options.ssrOptions?.platform !== Platform.Neutral; + + if (!isZonelessApp(options.polyfills)) { + serverPolyfills.push(isNodePlatform ? 'zone.js/node' : 'zone.js'); + } + + if ( + polyfillsFromConfig.has('@angular/localize') || + polyfillsFromConfig.has('@angular/localize/init') + ) { + serverPolyfills.push('@angular/localize/init'); + } + + serverPolyfills.push('@angular/platform-server/init'); + + const namespace = 'angular:polyfills-server'; + const polyfillBundleOptions = getEsBuildCommonPolyfillsOptions( + { + ...options, + polyfills: serverPolyfills, + }, + namespace, + false, + loadResultCache, + ); + + if (!polyfillBundleOptions) { + return; + } + + const jsBanner: string[] = []; + if (polyfillBundleOptions.external?.length) { + jsBanner.push(`globalThis['ngServerMode'] = true;`); + } + + if (isNodePlatform) { + // Note: Needed as esbuild does not provide require shims / proxy from ESModules. + // See: https://github.com/evanw/esbuild/issues/1921. + // Use an alias to avoid colliding with any `createRequire` import that may + // already exist in the bundled user code. ESBuild processes banner content + // as raw text outside of its module graph, so it cannot deduplicate or + // rename banner imports the way it does for user imports. Without the alias, + // a duplicate `import { createRequire }` binding would cause a runtime error. + jsBanner.push( + `import { createRequire as __ngCreateRequire } from 'node:module';`, + `globalThis['require'] ??= __ngCreateRequire(import.meta.url);`, + ); + } + + const buildOptions: BuildOptions = { + ...polyfillBundleOptions, + platform: isNodePlatform ? 'node' : 'neutral', + outExtension: { '.js': '.mjs' }, + // Note: `es2015` is needed for RxJS v6. If not specified, `module` would + // match and the ES5 distribution would be bundled and ends up breaking at + // runtime with the RxJS testing library. + // More details: https://github.com/angular/angular-cli/issues/25405. + mainFields: ['es2020', 'es2015', 'module', 'main'], + entryNames: '[name]', + banner: { + js: jsBanner.join('\n'), + }, + target, + entryPoints: { + 'polyfills.server': namespace, + }, + }; + + buildOptions.plugins ??= []; + buildOptions.plugins.push(createServerBundleMetadata(), createPlatformServerDominoExposePlugin()); + + return () => buildOptions; +} + +export function createServerMainCodeBundleOptions( + options: NormalizedApplicationBuildOptions, + target: string[], + sourceFileCache: SourceFileCache, + stylesheetBundler: ComponentStylesheetBundler, + angularCompilationContext: AngularCompilationContext, +): BundlerOptionsFactory { + const { + serverEntryPoint: mainServerEntryPoint, + workspaceRoot, + outputMode, + externalPackages, + ssrOptions, + polyfills, + } = options; + + assert( + mainServerEntryPoint, + 'createServerCodeBundleOptions should not be called without a defined serverEntryPoint.', + ); + + return (loadResultCache) => { + const pluginOptions = createCompilerPluginOptions(options, sourceFileCache, loadResultCache); + const mainServerNamespace = 'angular:main-server'; + const mainServerInjectManifestNamespace = 'angular:main-server-inject-manifest'; + const zoneless = isZonelessApp(polyfills); + const entryPoints: Record = { + 'main.server': mainServerNamespace, + }; + + const ssrEntryPoint = ssrOptions?.entry; + const isOldBehaviour = !outputMode; + + if (ssrEntryPoint && isOldBehaviour) { + // Old behavior: 'server.ts' was bundled together with the SSR (Server-Side Rendering) code. + // This approach combined server-side logic and rendering into a single bundle. + entryPoints['server'] = ssrEntryPoint; + } + + const buildOptions: BuildOptions = { + ...getEsBuildServerCommonOptions(options), + target, + banner: { + js: `import './polyfills.server.mjs';`, + }, + entryPoints, + supported: getFeatureSupport(zoneless), + }; + + buildOptions.plugins ??= []; + buildOptions.plugins.push( + createWasmPlugin({ allowAsync: zoneless, cache: loadResultCache }), + createAngularLocalizeInitWarningPlugin(), + createCompilerPlugin( + // JS/TS options + pluginOptions, + angularCompilationContext, + // Component stylesheet bundler + stylesheetBundler, + ), + ); + + if (!externalPackages) { + buildOptions.plugins.push(createRxjsEsmResolutionPlugin()); + } + + // Mark manifest and polyfills file as external as these are generated by a different bundle step. + (buildOptions.external ??= []).push(...SERVER_GENERATED_EXTERNALS); + const isNodePlatform = options.ssrOptions?.platform !== Platform.Neutral; + + if (!isNodePlatform) { + // `@angular/platform-server` lazily depends on `xhr2` for XHR usage with the HTTP client. + // Since `xhr2` has Node.js dependencies, it cannot be used when targeting non-Node.js platforms. + // Note: The framework already issues a warning when using XHR with SSR. + buildOptions.external.push('xhr2'); + } + + buildOptions.plugins.push( + createServerBundleMetadata(), + createPlatformServerDominoRedirectPlugin(), + createVirtualModulePlugin({ + namespace: mainServerInjectManifestNamespace, + cache: loadResultCache, + entryPointOnly: false, + loadContent: async () => { + const contents: string[] = [ + // Configure `@angular/ssr` manifest. + `import manifest from './${SERVER_APP_MANIFEST_FILENAME}';`, + `import { ɵsetAngularAppManifest } from '@angular/ssr';`, + `ɵsetAngularAppManifest(manifest);`, + ]; + + return { + contents: contents.join('\n'), + loader: 'js', + resolveDir: workspaceRoot, + }; + }, + }), + createVirtualModulePlugin({ + namespace: mainServerNamespace, + cache: loadResultCache, + loadContent: async () => { + const mainServerEntryPointJsImport = entryFileToWorkspaceRelative( + workspaceRoot, + mainServerEntryPoint, + ); + + const contents: string[] = [ + // Inject manifest + `import '${mainServerInjectManifestNamespace}';`, + + // Add @angular/ssr exports + `export { + ɵdestroyAngularServerApp, + ɵextractRoutesAndCreateRouteTree, + ɵgetOrCreateAngularServerApp, + } from '@angular/ssr';`, + + // Need for HMR + `export { ɵresetCompiledComponents } from '@angular/core';`, + + // Re-export all symbols including default export from 'main.server.ts' + `export { default } from ${JSON.stringify(mainServerEntryPointJsImport)};`, + `export * from ${JSON.stringify(mainServerEntryPointJsImport)};`, + ]; + + return { + contents: contents.join('\n'), + loader: 'js', + resolveDir: workspaceRoot, + }; + }, + }), + ); + + if (options.plugins) { + buildOptions.plugins.push(...options.plugins); + } + + return buildOptions; + }; +} + +export function createSsrEntryCodeBundleOptions( + options: NormalizedApplicationBuildOptions, + target: string[], + sourceFileCache: SourceFileCache, + stylesheetBundler: ComponentStylesheetBundler, + angularCompilationContext: AngularCompilationContext, +): BundlerOptionsFactory { + const { workspaceRoot, ssrOptions, externalPackages } = options; + const serverEntryPoint = ssrOptions?.entry; + assert( + serverEntryPoint, + 'createSsrEntryCodeBundleOptions should not be called without a defined serverEntryPoint.', + ); + + return (loadResultCache) => { + const pluginOptions = createCompilerPluginOptions(options, sourceFileCache, loadResultCache); + const ssrEntryNamespace = 'angular:ssr-entry'; + const ssrInjectManifestNamespace = 'angular:ssr-entry-inject-manifest'; + const isNodePlatform = options.ssrOptions?.platform !== Platform.Neutral; + + const jsBanner: string[] = []; + if (options.externalDependencies?.length) { + jsBanner.push(`globalThis['ngServerMode'] = true;`); + } + + if (isNodePlatform) { + // Note: Needed as esbuild does not provide require shims / proxy from ESModules. + // See: https://github.com/evanw/esbuild/issues/1921. + // Use an alias to avoid colliding with any `createRequire` import that may + // already exist in the bundled user code. ESBuild processes banner content + // as raw text outside of its module graph, so it cannot deduplicate or + // rename banner imports the way it does for user imports. Without the alias, + // a duplicate `import { createRequire }` binding would cause a runtime error. + jsBanner.push( + `import { createRequire as __ngCreateRequire } from 'node:module';`, + `globalThis['require'] ??= __ngCreateRequire(import.meta.url);`, + ); + } + + const buildOptions: BuildOptions = { + ...getEsBuildServerCommonOptions(options), + target, + banner: { + js: jsBanner.join('\n'), + }, + entryPoints: { + 'server': ssrEntryNamespace, + }, + supported: getFeatureSupport(true), + }; + + buildOptions.plugins ??= []; + buildOptions.plugins.push( + createAngularLocalizeInitWarningPlugin(), + createCompilerPlugin( + // JS/TS options + pluginOptions, + angularCompilationContext, + // Component stylesheet bundler + stylesheetBundler, + ), + ); + + if (!externalPackages) { + buildOptions.plugins.push(createRxjsEsmResolutionPlugin()); + } + + // Mark manifest file as external. As this will be generated later on. + (buildOptions.external ??= []).push('*/main.server.mjs', ...SERVER_GENERATED_EXTERNALS); + + if (!isNodePlatform) { + // `@angular/platform-server` lazily depends on `xhr2` for XHR usage with the HTTP client. + // Since `xhr2` has Node.js dependencies, it cannot be used when targeting non-Node.js platforms. + // Note: The framework already issues a warning when using XHR with SSR. + buildOptions.external.push('xhr2'); + } + + buildOptions.plugins.push( + createServerBundleMetadata({ ssrEntryBundle: true }), + createVirtualModulePlugin({ + namespace: ssrInjectManifestNamespace, + cache: loadResultCache, + entryPointOnly: false, + loadContent: () => { + const contents: string[] = [ + // Configure `@angular/ssr` app engine manifest. + `import manifest from './${SERVER_APP_ENGINE_MANIFEST_FILENAME}';`, + `import { ɵsetAngularAppEngineManifest } from '@angular/ssr';`, + `ɵsetAngularAppEngineManifest(manifest);`, + ]; + + return { + contents: contents.join('\n'), + loader: 'js', + resolveDir: workspaceRoot, + }; + }, + }), + createVirtualModulePlugin({ + namespace: ssrEntryNamespace, + cache: loadResultCache, + loadContent: () => { + const serverEntryPointJsImport = entryFileToWorkspaceRelative( + workspaceRoot, + serverEntryPoint, + ); + const contents: string[] = [ + // Configure `@angular/ssr` app engine manifest. + `import '${ssrInjectManifestNamespace}';`, + + // Re-export all symbols including default export + `import * as server from ${JSON.stringify(serverEntryPointJsImport)};`, + `export * from ${JSON.stringify(serverEntryPointJsImport)};`, + // The below is needed to avoid + // `Import "default" will always be undefined because there is no matching export` warning when no default is present. + `const defaultExportName = 'default';`, + `export default server[defaultExportName];`, + + // Add @angular/ssr exports + `export { AngularAppEngine } from '@angular/ssr';`, + ]; + + return { + contents: contents.join('\n'), + loader: 'js', + resolveDir: workspaceRoot, + }; + }, + }), + ); + + if (options.plugins) { + buildOptions.plugins.push(...options.plugins); + } + + return buildOptions; + }; +} + +function getEsBuildServerCommonOptions(options: NormalizedApplicationBuildOptions): BuildOptions { + const isNodePlatform = options.ssrOptions?.platform !== Platform.Neutral; + + const commonOptions = getEsBuildCommonOptions(options); + commonOptions.define ??= {}; + commonOptions.define['ngServerMode'] = 'true'; + + return { + ...commonOptions, + platform: isNodePlatform ? 'node' : 'neutral', + outExtension: { '.js': '.mjs' }, + // Note: `es2015` is needed for RxJS v6. If not specified, `module` would + // match and the ES5 distribution would be bundled and ends up breaking at + // runtime with the RxJS testing library. + // More details: https://github.com/angular/angular-cli/issues/25405. + mainFields: ['es2020', 'es2015', 'module', 'main'], + entryNames: '[name]', + }; +} + +function getEsBuildCommonOptions(options: NormalizedApplicationBuildOptions): BuildOptions { + const { + workspaceRoot, + outExtension, + optimizationOptions, + sourcemapOptions, + tsconfig, + externalDependencies, + outputNames, + preserveSymlinks, + jit, + loaderExtensions, + jsonLogs, + i18nOptions, + customConditions, + frameworkVersion, + } = options; + + // Ensure unique hashes for i18n translation changes when using post-process inlining. + // This hash value is added as a footer to each file and ensures that the output file names (with hashes) + // change when translation files have changed. If this is not done the post processed files may have + // different content but would retain identical production file names which would lead to browser caching problems. + let footer; + if (i18nOptions.shouldInline) { + // Update file hashes to include translation file content + const i18nHash = Object.values(i18nOptions.locales).reduce( + (data, locale) => data + locale.files.map((file) => file.integrity || '').join('|'), + '', + ); + + footer = { js: `/**i18n:${calculateHash(i18nHash)}*/` }; + } + + // Core conditions that are always included + const conditions = [ + // Required to support rxjs 7.x which will use es5 code if this condition is not present + 'es2015', + 'es2020', + ]; + // The pre-linked code is not used with JIT for two reasons: + // 1) The pre-linked code may not have the metadata included that is required for JIT + // 2) The CLI is otherwise setup to use runtime linking for JIT to match the application template compilation + if (!jit) { + // The pre-linked package condition is based on the framework version. + // Currently this is specific to each patch version of the framework. + conditions.push('angular:linked-' + frameworkVersion); + } + + // Append custom conditions if present + if (customConditions) { + conditions.push(...customConditions); + } else { + // Include default conditions + conditions.push('module', optimizationOptions.scripts ? 'production' : 'development'); + } + + const plugins: Plugin[] = [ + createLoaderImportAttributePlugin(), + createSourcemapIgnorelistPlugin(), + ]; + + let packages: BuildOptions['packages'] = 'bundle'; + if (options.externalPackages) { + // Package files affected by a customized loader should not be implicitly marked as external + if ( + options.loaderExtensions || + options.plugins || + typeof options.externalPackages === 'object' + ) { + // Plugin must be added after custom plugins to ensure any added loader options are considered + plugins.push( + createExternalPackagesPlugin( + options.externalPackages !== true ? options.externalPackages : undefined, + ), + ); + + packages = 'bundle'; + } else { + // Safe to use the packages external option directly + packages = 'external'; + } + } + + return { + absWorkingDir: workspaceRoot, + format: 'esm', + bundle: true, + packages, + assetNames: outputNames.media, + conditions, + resolveExtensions: ['.ts', '.tsx', '.mjs', '.js', '.cjs'], + metafile: true, + legalComments: options.extractLicenses + ? 'none' + : optimizationOptions.scripts + ? 'eof' + : 'inline', + logLevel: options.verbose && !jsonLogs ? 'debug' : 'silent', + minifyIdentifiers: optimizationOptions.scripts && allowMangle, + minifySyntax: optimizationOptions.scripts, + minifyWhitespace: optimizationOptions.scripts, + pure: ['forwardRef'], + outdir: workspaceRoot, + outExtension: outExtension ? { '.js': `.${outExtension}` } : undefined, + sourcemap: sourcemapOptions.scripts && (sourcemapOptions.hidden ? 'external' : true), + sourcesContent: sourcemapOptions.sourcesContent, + splitting: true, + chunkNames: options.namedChunks ? '[name]-[hash]' : 'chunk-[hash]', + tsconfig, + external: externalDependencies ? [...externalDependencies] : undefined, + write: false, + preserveSymlinks, + define: { + ...options.define, + // Only set to false when script optimizations are enabled. It should not be set to true because + // Angular turns `ngDevMode` into an object for development debugging purposes when not defined + // which a constant true value would break. + ...(optimizationOptions.scripts ? { 'ngDevMode': 'false' } : undefined), + 'ngJitMode': jit ? 'true' : 'false', + 'ngServerMode': 'false', + 'ngHmrMode': options.templateUpdates ? 'true' : 'false', + }, + loader: loaderExtensions, + footer, + plugins, + }; +} + +function getEsBuildCommonPolyfillsOptions( + options: NormalizedApplicationBuildOptions, + namespace: string, + tryToResolvePolyfillsAsRelative: boolean, + loadResultCache: LoadResultCache | undefined, +): BuildOptions | undefined { + const { jit, workspaceRoot, i18nOptions, externalPackages } = options; + + let polyfills = options.polyfills ? [...options.polyfills] : []; + + const buildOptions = getEsBuildCommonOptions({ + ...options, + // If any polyfills are local files, disable external packages for the polyfills build. + // This ensures that local files are properly bundled. + externalPackages: polyfills.some(isLocalFile) ? false : externalPackages, + }); + buildOptions.splitting = false; + buildOptions.plugins ??= []; + + // Angular JIT mode requires the runtime compiler + if (jit) { + polyfills.unshift('@angular/compiler'); + } + + // Add Angular's global locale data if i18n options are present. + let needLocaleDataPlugin = false; + if (i18nOptions.shouldInline) { + // Remove localize polyfill when i18n inline transformation have been applied to all the packages. + polyfills = polyfills.filter((path) => !path.startsWith('@angular/localize')); + } else if (i18nOptions.hasDefinedSourceLocale) { + // When not inlining and a source locale is present, use the source locale data directly + polyfills.unshift(`angular:locale/data:${i18nOptions.sourceLocale}`); + needLocaleDataPlugin = true; + } + if (needLocaleDataPlugin) { + buildOptions.plugins.unshift(createAngularLocaleDataPlugin()); + } + + if (polyfills.length === 0 && !i18nOptions.shouldInline && !i18nOptions.hasDefinedSourceLocale) { + return; + } + + buildOptions.plugins.push( + createVirtualModulePlugin({ + namespace, + cache: loadResultCache, + loadContent: async (_, build) => { + let polyfillPaths = polyfills; + + if (tryToResolvePolyfillsAsRelative) { + polyfillPaths = await Promise.all( + polyfills.map(async (path) => { + if (path.startsWith('zone.js') || !extname(path)) { + return path; + } + + const potentialPathRelative = './' + path; + const result = await build.resolve(potentialPathRelative, { + kind: 'import-statement', + resolveDir: workspaceRoot, + }); + + return result.path ? potentialPathRelative : path; + }), + ); + } + + // Generate module contents with an import statement per defined polyfill + let contents = polyfillPaths + .map((file) => `import ${JSON.stringify(toPosixPath(file))};`) + .join('\n'); + + // The below should be done after loading `$localize` as otherwise the locale will be overridden. + if (i18nOptions.shouldInline) { + // When inlining, a placeholder is used to allow the post-processing step to inject the $localize locale identifier. + contents += '(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n'; + } else if (i18nOptions.hasDefinedSourceLocale) { + // If not inlining translations and source locale is defined, inject the locale specifier. + contents += `(globalThis.$localize ??= {}).locale = "${i18nOptions.sourceLocale}";\n`; + } + + return { + contents, + loader: 'js', + resolveDir: workspaceRoot, + }; + }, + }), + ); + + return buildOptions; +} + +function entryFileToWorkspaceRelative(workspaceRoot: string, entryFile: string): string { + return './' + toPosixPath(relative(workspaceRoot, entryFile).replace(/\.[mc]?ts$/, '')); +} + +/** + * Determines if a polyfill path is a local file. + * A local file is defined as a path starting with a `.` or having a TypeScript/JavaScript extension. + * `zone.js` and its subpaths are specifically excluded and treated as packages. + * @param path The polyfill path to check. + * @returns true if the path is a local file; false otherwise. + */ +function isLocalFile(path: string): boolean { + if (path.startsWith('zone.js')) { + return false; + } + + return path[0] === '.' || /\.[mc]?[jt]sx?$/.test(path); +} diff --git a/packages/angular/build/src/tools/esbuild/budget-stats.ts b/packages/angular/build/src/tools/esbuild/budget-stats.ts new file mode 100644 index 000000000000..a14d9a24f1b6 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/budget-stats.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Metafile } from 'esbuild'; +import type { BudgetStats } from '../../utils/bundle-calculator'; +import { type BuildOutputFile, BuildOutputFileType, type InitialFileRecord } from './bundler-files'; +import { getChunkNameFromMetafile } from './utils'; + +/** + * Generates a bundle budget calculator compatible stats object that provides + * the necessary information for the Webpack-based bundle budget code to + * interoperate with the esbuild-based builders. + * @param metafile The esbuild metafile of a build to use. + * @param initialFiles The records of all initial files of a build. + * @returns A bundle budget compatible stats object. + */ +export function generateBudgetStats( + metafile: Metafile, + outputFiles: BuildOutputFile[], + initialFiles: Map, +): BudgetStats { + const stats: Required = { + chunks: [], + assets: [], + }; + + for (const { path: file, size, type } of outputFiles) { + // Exclude server bundles + if (type === BuildOutputFileType.ServerApplication || type === BuildOutputFileType.ServerRoot) { + continue; + } + + if (!file.endsWith('.js') && !file.endsWith('.css')) { + continue; + } + + const initialRecord = initialFiles.get(file); + const name = initialRecord?.name ?? getChunkNameFromMetafile(metafile, file); + + stats.chunks.push({ + files: [file], + initial: !!initialRecord, + names: name ? [name] : undefined, + }); + + stats.assets.push({ + name: file, + size, + }); + } + + // Add component styles from metafile + // TODO: Provide this information directly from the AOT compiler + for (const [file, entry] of Object.entries(metafile.outputs)) { + if (!file.endsWith('.css')) { + continue; + } + // 'ng-component' is set by the angular plugin's component stylesheet bundler + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const componentStyle: boolean = (entry as any)['ng-component']; + if (!componentStyle) { + continue; + } + + stats.assets.push({ + // Component styles use the input file + name: Object.keys(entry.inputs)[0], + size: entry.bytes, + componentStyle, + }); + } + + return stats; +} diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts new file mode 100644 index 000000000000..106619be2360 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -0,0 +1,601 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + BuildContext, + BuildFailure, + BuildOptions, + BuildResult, + Message, + Metafile, + build, + context, +} from 'esbuild'; +import assert from 'node:assert'; +import { basename, extname, isAbsolute, join, normalize, relative } from 'node:path'; +import { SERVER_GENERATED_EXTERNALS } from '../../utils/server-rendering/manifest'; +import { + type BuildOutputFile, + BuildOutputFileType, + type InitialFileRecord, + convertOutputFile, + findTransitiveInitialFiles, +} from './bundler-files'; +import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache'; + +export type BundleContextResult = + | { errors: Message[]; warnings: Message[] } + | { + errors: undefined; + warnings: Message[]; + metafile: Metafile; + platform: 'browser' | 'server'; + outputFiles: BuildOutputFile[]; + initialFiles: Map; + externalImports: Set; + externalConfiguration?: string[]; + }; + +export type BundleMergedContextResult = + | { errors: Message[]; warnings: Message[] } + | { + errors: undefined; + warnings: Message[]; + metafiles: { + browser: Metafile; + server: Metafile; + }; + outputFiles: BuildOutputFile[]; + initialFiles: Map; + externalImports: { + server: Set; + browser: Set; + }; + externalConfiguration?: string[]; + }; + +export type BundlerOptionsFactory = ( + loadCache: LoadResultCache | undefined, +) => T; + +/** + * Determines if an unknown value is an esbuild BuildFailure error object thrown by esbuild. + * @param value A potential esbuild BuildFailure error object. + * @returns `true` if the object is determined to be a BuildFailure object; otherwise, `false`. + */ +function isEsBuildFailure(value: unknown): value is BuildFailure { + return !!value && typeof value === 'object' && 'errors' in value && 'warnings' in value; +} + +export class BundlerContext { + #esbuildContext?: BuildContext<{ metafile: true; write: false }>; + #esbuildOptions?: BuildOptions & { metafile: true; write: false }; + #esbuildResult?: BundleContextResult; + #activeBundlePromise?: Promise; + #disposed = false; + #optionsFactory: BundlerOptionsFactory; + #shouldCacheResult: boolean; + #loadCache?: LoadResultCache; + #loadCacheKeys?: Set; + #invalidationEpoch = 0; + readonly watchFiles = new Set(); + + constructor( + private workspaceRoot: string, + private incremental: boolean, + options: BuildOptions | BundlerOptionsFactory, + private useContext = incremental, + private initialFilter?: (initial: Readonly) => boolean, + sharedLoadCache?: LoadResultCache, + ) { + this.#loadCache = sharedLoadCache; + // To cache the results an option factory is needed to capture the full set of dependencies + this.#shouldCacheResult = incremental && typeof options === 'function'; + this.#optionsFactory = (...args) => { + const baseOptions = typeof options === 'function' ? options(...args) : options; + + return { + ...baseOptions, + metafile: true, + write: false, + }; + }; + } + + static bundleAll( + contexts: Iterable, + changedFiles?: Iterable | ReadonlySet, + ): Promise { + return Promise.all( + [...contexts].map((context) => { + if (changedFiles) { + context.invalidate(changedFiles); + } + + return context.bundle(); + }), + ); + } + + static mergeResults(results: BundleContextResult[]): BundleMergedContextResult { + let errors: Message[] | undefined; + const warnings: Message[] = []; + const browserMetafile: Metafile = { inputs: {}, outputs: {} }; + const serverMetafile: Metafile = { inputs: {}, outputs: {} }; + const initialFiles = new Map(); + const externalImportsBrowser = new Set(); + const externalImportsServer = new Set(); + + const outputFiles: BuildOutputFile[] = []; + let externalConfiguration: Set | undefined; + for (const result of results) { + warnings.push(...result.warnings); + if (result.errors) { + errors ??= []; + errors.push(...result.errors); + continue; + } + + const platformIsBrowser = result.platform === 'browser'; + + // Combine metafiles used for the bundle budgets and console output + if (result.metafile) { + const metafile = platformIsBrowser ? browserMetafile : serverMetafile; + Object.assign(metafile.inputs, result.metafile.inputs); + Object.assign(metafile.outputs, result.metafile.outputs); + } + + const externalImports = platformIsBrowser ? externalImportsBrowser : externalImportsServer; + result.externalImports?.forEach((value) => externalImports.add(value)); + + result.initialFiles.forEach((value, key) => initialFiles.set(key, value)); + outputFiles.push(...result.outputFiles); + + if (result.externalConfiguration) { + externalConfiguration ??= new Set(); + for (const value of result.externalConfiguration) { + externalConfiguration.add(value); + } + } + } + + if (errors !== undefined) { + return { errors, warnings }; + } + + return { + errors, + warnings, + initialFiles, + outputFiles, + externalImports: { + browser: externalImportsBrowser, + server: externalImportsServer, + }, + metafiles: { + browser: browserMetafile, + server: serverMetafile, + }, + externalConfiguration: externalConfiguration ? [...externalConfiguration] : undefined, + }; + } + + /** + * Executes the esbuild build function and normalizes the build result in the event of a + * build failure that results in no output being generated. + * All builds use the `write` option with a value of `false` to allow for the output files + * build result array to be populated. + * + * @param force If true, always rebundle. + * @returns If output files are generated, the full esbuild BuildResult; if not, the + * warnings and errors for the attempted build. + */ + async bundle(force?: boolean): Promise { + // Return existing result if present + if (!force && this.#esbuildResult) { + return this.#esbuildResult; + } + + if (!force && this.#activeBundlePromise) { + return this.#activeBundlePromise; + } + + const bundleEpoch = this.#invalidationEpoch; + const bundlePromise = this.#performBundle().finally(() => { + if (this.#activeBundlePromise === bundlePromise) { + this.#activeBundlePromise = undefined; + } + }); + this.#activeBundlePromise = bundlePromise; + + const result = await bundlePromise; + if (this.#shouldCacheResult && bundleEpoch === this.#invalidationEpoch) { + this.#esbuildResult = result; + } + + return result; + } + + // eslint-disable-next-line max-lines-per-function + async #performBundle(): Promise { + this.#loadCacheKeys?.clear(); + + // Create esbuild options if not present + if (this.#esbuildOptions === undefined) { + if (this.incremental && !this.#loadCache) { + this.#loadCache = new MemoryLoadResultCache(); + } + const loadCache = this.#loadCache; + const scopedLoadCache: LoadResultCache | undefined = + this.incremental && loadCache + ? { + get: (path) => { + (this.#loadCacheKeys ??= new Set()).add(path); + + return loadCache.get(path); + }, + put: (path, result) => { + (this.#loadCacheKeys ??= new Set()).add(path); + + return loadCache.put(path, result); + }, + invalidate: (path) => loadCache.invalidate(path), + get watchFiles() { + return loadCache.watchFiles; + }, + } + : loadCache; + this.#esbuildOptions = this.#optionsFactory(scopedLoadCache); + } + + let result: BuildResult<{ metafile: true; write: false }>; + try { + if (this.#esbuildContext) { + // Rebuild using the existing incremental build context + result = await this.#esbuildContext.rebuild(); + } else if (this.useContext) { + // Create a build context and perform the build. + // Context creation does not perform a build. + const esbuildContext = await context(this.#esbuildOptions); + if (this.#disposed) { + await esbuildContext.dispose(); + throw new Error('BundlerContext was disposed during build.'); + } + this.#esbuildContext = esbuildContext; + result = await this.#esbuildContext.rebuild(); + } else { + // For non-incremental builds, perform a single build + if (this.#disposed) { + throw new Error('BundlerContext was disposed during build.'); + } + result = await build(this.#esbuildOptions); + if (this.#disposed) { + throw new Error('BundlerContext was disposed during build.'); + } + } + } catch (failure) { + // Build failures will throw an exception which contains errors/warnings + if (isEsBuildFailure(failure)) { + this.#addErrorsToWatch(failure); + await this.#addLoadCacheFilesToWatch(); + + return failure; + } else { + throw failure; + } + } + + // Update files that should be watched. + // While this should technically not be linked to incremental mode, incremental is only + // currently enabled with watch mode where watch files are needed. + if (this.incremental) { + this.watchFiles.clear(); + + // Add input files except virtual angular files which do not exist on disk + for (const input of Object.keys(result.metafile.inputs)) { + const isInternal = isInternalAngularFile(input) || isInternalBundlerFile(input); + + // Input file paths are always relative to the workspace root unless already absolute + const normalizedAbsoluteInput = isAbsolute(input) + ? normalize(input) + : join(this.workspaceRoot, input); + + if (!isInternal) { + this.watchFiles.add(normalizedAbsoluteInput); + } + + if (this.#loadCache) { + const cachedLoad = + (await this.#loadCache.get(input)) ?? + (await this.#loadCache.get(input.replace(';', ':'))) ?? + (await this.#loadCache.get('file:' + normalizedAbsoluteInput)); + if (cachedLoad?.watchFiles) { + for (const file of cachedLoad.watchFiles) { + if (!isInternalAngularFile(file)) { + this.watchFiles.add( + isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file), + ); + } + } + } + } + } + } + + // Return if the build encountered any errors + if (result.errors.length) { + this.#addErrorsToWatch(result); + await this.#addLoadCacheFilesToWatch(); + + return { + errors: result.errors, + warnings: result.warnings, + }; + } + + const { + 'ng-platform-server': isPlatformServer = false, + 'ng-ssr-entry-bundle': isSsrEntryBundle = false, + } = result.metafile as Metafile & { + 'ng-platform-server'?: boolean; + 'ng-ssr-entry-bundle'?: boolean; + }; + + // Find all initial files + const initialFiles = new Map(); + for (const outputFile of result.outputFiles) { + // Entries in the metafile are relative to the `absWorkingDir` option which is set to the workspaceRoot + // `outputFile.path` may already be relative if the `OutputFile` instance was cached in `additionalResults` + // across an incremental rebuild. + const relativeFilePath = isAbsolute(outputFile.path) + ? relative(this.workspaceRoot, outputFile.path) + : outputFile.path; + const entryPoint = result.metafile.outputs[relativeFilePath]?.entryPoint; + + outputFile.path = relativeFilePath; + + if (entryPoint) { + // The first part of the filename is the name of file (e.g., "polyfills" for "polyfills-7S5G3MDY.js") + const name = basename(relativeFilePath).replace(/(?:-[\dA-Z]{8})?\.[a-z]{2,3}$/, ''); + // Entry points are only styles or scripts + const type = extname(relativeFilePath) === '.css' ? 'style' : 'script'; + + // Only entrypoints with an entry in the options are initial files. + // Dynamic imports also have an entryPoint value in the meta file. + if ((this.#esbuildOptions.entryPoints as Record)?.[name]) { + // An entryPoint value indicates an initial file + const record: InitialFileRecord = { + name, + type, + entrypoint: true, + serverFile: isPlatformServer, + depth: 0, + }; + + if (!this.initialFilter || this.initialFilter(record)) { + initialFiles.set(relativeFilePath, record); + } + } + } + } + + // Analyze for transitive initial files + findTransitiveInitialFiles(result.metafile, initialFiles, undefined, this.initialFilter); + + // Collect all external package names + const externalImports = new Set(); + for (const { imports } of Object.values(result.metafile.outputs)) { + for (const { external, kind, path } of imports) { + if ( + !external || + SERVER_GENERATED_EXTERNALS.has(path) || + isInternalAngularFile(path) || + (kind !== 'import-statement' && kind !== 'dynamic-import' && kind !== 'require-call') + ) { + continue; + } + + externalImports.add(path); + } + } + + assert(this.#esbuildOptions, 'esbuild options cannot be undefined.'); + + const outputFiles = result.outputFiles.map((file) => { + let fileType: BuildOutputFileType; + // All files that are not JS, CSS, WASM, or sourcemaps for them are considered media + if (!/\.([cm]?js|css|wasm)(\.map)?$/i.test(file.path)) { + fileType = BuildOutputFileType.Media; + } else if (isPlatformServer) { + fileType = isSsrEntryBundle + ? BuildOutputFileType.ServerRoot + : BuildOutputFileType.ServerApplication; + } else { + fileType = BuildOutputFileType.Browser; + } + + return convertOutputFile(file, fileType); + }); + + let externalConfiguration = this.#esbuildOptions.external; + if (isPlatformServer && externalConfiguration) { + externalConfiguration = externalConfiguration.filter( + (dep) => !SERVER_GENERATED_EXTERNALS.has(dep), + ); + + if (!externalConfiguration.length) { + externalConfiguration = undefined; + } + } + + // Return the successful build results + return { + ...result, + outputFiles, + initialFiles, + externalImports, + platform: isPlatformServer ? 'server' : 'browser', + externalConfiguration, + errors: undefined, + }; + } + + #addErrorsToWatch(result: BuildFailure | BuildResult): void { + const entryPoints = this.#esbuildOptions?.entryPoints; + if (entryPoints) { + const entries = Array.isArray(entryPoints) + ? entryPoints.map((entry) => (typeof entry === 'string' ? entry : entry.in)) + : Object.values(entryPoints); + for (const entry of entries) { + if (isInternalAngularFile(entry) || isInternalBundlerFile(entry)) { + continue; + } + + this.watchFiles.add(isAbsolute(entry) ? normalize(entry) : join(this.workspaceRoot, entry)); + } + } + + for (const error of result.errors) { + let file = error.location?.file; + if (file && !isInternalAngularFile(file)) { + this.watchFiles.add(isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file)); + } + for (const note of error.notes ?? []) { + file = note.location?.file; + if (file && !isInternalAngularFile(file)) { + this.watchFiles.add(isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file)); + } + } + } + } + + async #addLoadCacheFilesToWatch(): Promise { + const loadCache = this.#loadCache; + if (!this.incremental || !loadCache || !this.#loadCacheKeys) { + return; + } + + const results = await Promise.all(Array.from(this.#loadCacheKeys, (key) => loadCache.get(key))); + for (const cachedResult of results) { + if (!cachedResult?.watchFiles) { + continue; + } + + for (const file of cachedResult.watchFiles) { + if (isInternalAngularFile(file)) { + continue; + } + + this.watchFiles.add(isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file)); + } + } + } + + /** + * Invalidate a stored bundler result based on the previous watch files + * and a list of changed files. + * The context must be created with incremental mode enabled for results + * to be stored. + * @returns True, if the result was invalidated; False, otherwise. + */ + invalidate(files: Iterable | ReadonlySet): boolean { + if (!this.incremental) { + return false; + } + + let candidateFiles: ReadonlySet; + if (files instanceof Set) { + let isCandidateReady = true; + for (const file of files) { + if ( + file !== normalize(file) || + (!isAbsolute(file) && !files.has(normalize(join(this.workspaceRoot, file)))) + ) { + isCandidateReady = false; + break; + } + } + + if (isCandidateReady) { + candidateFiles = files; + } else { + const normalizedFiles = new Set(); + for (const file of files) { + const normalized = normalize(file); + normalizedFiles.add(normalized); + if (!isAbsolute(normalized)) { + normalizedFiles.add(normalize(join(this.workspaceRoot, normalized))); + } + } + candidateFiles = normalizedFiles; + } + } else { + const normalizedFiles = new Set(); + for (const file of files) { + const normalized = normalize(file); + normalizedFiles.add(normalized); + if (!isAbsolute(normalized)) { + normalizedFiles.add(normalize(join(this.workspaceRoot, normalized))); + } + } + candidateFiles = normalizedFiles; + } + + if (this.#loadCache) { + for (const file of candidateFiles) { + this.#loadCache.invalidate(file); + } + } + + const invalid = !this.watchFiles.isDisjointFrom(candidateFiles); + + if (invalid) { + this.#invalidationEpoch++; + this.#esbuildResult = undefined; + } + + return invalid; + } + + /** + * Disposes incremental build resources present in the context. + * + * @returns A promise that resolves when disposal is complete. + */ + async dispose(): Promise { + this.#disposed = true; + try { + this.#esbuildOptions = undefined; + this.#esbuildResult = undefined; + this.#activeBundlePromise = undefined; + this.#loadCache = undefined; + this.#loadCacheKeys = undefined; + await this.#esbuildContext?.dispose(); + } finally { + this.#esbuildContext = undefined; + } + } +} + +function isInternalAngularFile(file: string) { + return file.startsWith('angular:'); +} + +function isInternalBundlerFile(file: string) { + // Bundler virtual files such as "" or "" + if (file[0] === '<' && file.at(-1) === '>') { + return true; + } + + // Any (disabled): path is a virtual esbuild entry that doesn't exist on disk + if (file.includes('(disabled):')) { + return true; + } + + return false; +} diff --git a/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts b/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts new file mode 100644 index 000000000000..b98f266e36a7 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts @@ -0,0 +1,221 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { BundlerContext } from './bundler-context'; +import { MemoryLoadResultCache, createCachedLoad } from './load-result-cache'; + +describe('BundlerContext', () => { + let tempRoot: string; + + beforeAll(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-bundler-context-test-')); + }); + + describe('invalidate', () => { + it('should return false when incremental is disabled', () => { + const context = new BundlerContext('/workspace', /* incremental */ false, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/app.css'); + + expect(context.invalidate(['/workspace/src/app.css'])).toBeFalse(); + }); + + it('should return true when a watch file matches changed files as an array', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/app.css'); + + expect(context.invalidate(['/workspace/src/app.css'])).toBeTrue(); + }); + + it('should return true when a watch file matches changed files as a ReadonlySet', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/app.css'); + + const changedSet: ReadonlySet = new Set(['/workspace/src/app.css']); + expect(context.invalidate(changedSet)).toBeTrue(); + }); + + it('should return false when changed files do not intersect with watchFiles', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/app.css'); + + expect(context.invalidate(['/workspace/src/other.css'])).toBeFalse(); + }); + + it('should correctly handle relative changed file paths', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/app.css'); + + expect(context.invalidate(['src/app.css'])).toBeTrue(); + }); + + it('should correctly handle relative paths inside a ReadonlySet', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/app.css'); + + const set: ReadonlySet = new Set(['src/app.css']); + expect(context.invalidate(set)).toBeTrue(); + }); + + it('should invalidate shared load cache when files change', async () => { + const loadCache = new MemoryLoadResultCache(); + await loadCache.put('file:/workspace/src/app.css', { + contents: 'body {}', + loader: 'css', + watchFiles: ['/workspace/src/app.css'], + }); + + const context = new BundlerContext( + '/workspace', + /* incremental */ true, + () => ({ entryPoints: ['main.js'] }), + /* useContext */ false, + /* initialFilter */ undefined, + loadCache, + ); + context.watchFiles.add('/workspace/src/app.css'); + + expect(loadCache.get('file:/workspace/src/app.css')).toBeDefined(); + expect(context.invalidate(['/workspace/src/app.css'])).toBeTrue(); + expect(loadCache.get('file:/workspace/src/app.css')).toBeUndefined(); + }); + + it('should not invalidate unrelated contexts sharing a load cache', async () => { + const workspaceDir = await mkdtemp(join(tempRoot, 'shared-cache-')); + const compAPath = join(workspaceDir, 'comp-a.css'); + const compBPath = join(workspaceDir, 'comp-b.css'); + await writeFile(compAPath, '.a { color: red; }'); + await writeFile(compBPath, '.b { color: blue; }'); + + const sharedCache = new MemoryLoadResultCache(); + const createCssContext = (entry: string) => + new BundlerContext( + workspaceDir, + /* incremental */ true, + (loadCache) => ({ + absWorkingDir: workspaceDir, + bundle: true, + entryPoints: [entry], + outdir: workspaceDir, + plugins: [ + { + name: 'test-cached-css', + setup(build) { + build.onLoad( + { filter: /\.css$/ }, + createCachedLoad(loadCache, async (args) => ({ + contents: await readFile(args.path, 'utf8'), + loader: 'css', + watchFiles: [args.path], + })), + ); + }, + }, + ], + }), + /* useContext */ false, + /* initialFilter */ undefined, + sharedCache, + ); + + const contextA = createCssContext(compAPath); + const contextB = createCssContext(compBPath); + + const resultA1 = await contextA.bundle(); + const resultB1 = await contextB.bundle(); + expect(resultA1.errors).toBeUndefined(); + expect(resultB1.errors).toBeUndefined(); + + await writeFile(compAPath, '.a { color: green; }'); + + // Even if contextB.invalidate runs first against the shared cache, contextB must not be invalidated + expect(contextB.invalidate([compAPath])).toBeFalse(); + expect(contextA.invalidate([compAPath])).toBeTrue(); + + const resultB2 = await contextB.bundle(); + const resultA2 = await contextA.bundle(); + expect(resultB2).toBe(resultB1); + expect(resultA2).not.toBe(resultA1); + assert(!resultA2.errors); + expect(resultA2.outputFiles[0].text).toContain('green'); + }); + + it('should work when watchFiles is smaller than changed files', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/file10.css'); + + const changedFiles = Array.from({ length: 100 }, (_, i) => `/workspace/src/file${i}.css`); + expect(context.invalidate(new Set(changedFiles))).toBeTrue(); + }); + + it('should work when changed files is smaller than watchFiles', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + for (let i = 0; i < 100; i++) { + context.watchFiles.add(`/workspace/src/file${i}.css`); + } + + expect(context.invalidate(new Set(['/workspace/src/file50.css']))).toBeTrue(); + }); + }); + + describe('bundle', () => { + it('should recover when fixing a broken @import in plain CSS in watch mode', async () => { + const workspaceDir = await mkdtemp(join(tempRoot, 'css-import-recovery-')); + const compCssPath = join(workspaceDir, 'comp.css'); + const subCssPath = join(workspaceDir, 'sub.css'); + await writeFile(compCssPath, "@import './sub.css';"); + await writeFile(subCssPath, "@import './non-existent.css';"); + + const context = new BundlerContext( + workspaceDir, + /* incremental */ true, + () => ({ + absWorkingDir: workspaceDir, + bundle: true, + entryPoints: [compCssPath], + outdir: workspaceDir, + logLevel: 'silent', + }), + /* useContext */ false, + ); + + const failedResult = await context.bundle(); + expect(failedResult.errors).toBeDefined(); + expect(context.watchFiles.has(subCssPath)).toBeTrue(); + expect(context.watchFiles.has(compCssPath)).toBeTrue(); + + // Fix sub.css and verify invalidation + recovery + await writeFile(subCssPath, '.sub { color: purple; }'); + expect(context.invalidate([subCssPath])).toBeTrue(); + + const recoveredResult = await context.bundle(); + assert(!recoveredResult.errors); + expect(recoveredResult.outputFiles[0].text).toContain('purple'); + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts b/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts new file mode 100644 index 000000000000..3777d2835f5a --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts @@ -0,0 +1,216 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Message, PartialMessage } from 'esbuild'; +import { normalize } from 'node:path'; +import type { ChangedFiles } from '../../utils/watcher'; +import type { ComponentStylesheetBundler } from './angular/component-stylesheets'; +import type { SourceFileCache } from './angular/source-file-cache'; +import type { BundlerContext } from './bundler-context'; +import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files'; + +export interface BuildOutputAsset { + source: string; + destination: string; +} + +export interface RebuildState { + rebuildContexts: { + typescriptContexts: BundlerContext[]; + otherContexts: BundlerContext[]; + }; + componentStyleBundler: ComponentStylesheetBundler; + codeBundleCache?: SourceFileCache; + fileChanges: ChangedFiles; + previousOutputInfo: ReadonlyMap< + string, + { hash: string; type: BuildOutputFileType; path: string } + >; + previousAssetsInfo: ReadonlySet; + templateUpdates?: Map; +} + +export interface ExternalResultMetadata { + implicitBrowser: string[]; + implicitServer: string[]; + explicit: string[]; +} + +export type PrerenderedRoutesRecord = Record }>; + +/** + * Represents the result of a single builder execute call. + */ +export class ExecutionResult { + outputFiles: BuildOutputFile[] = []; + assetFiles: BuildOutputAsset[] = []; + errors: (Message | PartialMessage)[] = []; + prerenderedRoutes: PrerenderedRoutesRecord = {}; + warnings: (Message | PartialMessage)[] = []; + logs: string[] = []; + externalMetadata?: ExternalResultMetadata; + extraWatchFiles: string[] = []; + htmlIndexPath?: string; + htmlBaseHref?: string; + + constructor( + private rebuildContexts: { + typescriptContexts: BundlerContext[]; + otherContexts: BundlerContext[]; + }, + private componentStyleBundler: ComponentStylesheetBundler, + private codeBundleCache?: SourceFileCache, + readonly templateUpdates?: Map, + ) {} + + addOutputFile(path: string, content: string | Uint8Array, type: BuildOutputFileType): void { + this.outputFiles.push(createOutputFile(path, content, type)); + } + + addAssets(assets: BuildOutputAsset[]): void { + this.assetFiles.push(...assets); + } + + addLog(value: string): void { + this.logs.push(value); + } + + addError(error: PartialMessage | string): void { + if (typeof error === 'string') { + this.errors.push({ text: error, location: null }); + } else { + this.errors.push(error); + } + } + + addErrors(errors: (PartialMessage | string)[]): void { + for (const error of errors) { + this.addError(error); + } + } + + addPrerenderedRoutes(routes: PrerenderedRoutesRecord): void { + Object.assign(this.prerenderedRoutes, routes); + + // Sort the prerendered routes. + const sortedObj: PrerenderedRoutesRecord = {}; + for (const key of Object.keys(this.prerenderedRoutes).sort()) { + sortedObj[key] = this.prerenderedRoutes[key]; + } + + this.prerenderedRoutes = sortedObj; + } + + addWarning(error: PartialMessage | string): void { + if (typeof error === 'string') { + this.warnings.push({ text: error, location: null }); + } else { + this.warnings.push(error); + } + } + + addWarnings(errors: (PartialMessage | string)[]): void { + for (const error of errors) { + this.addWarning(error); + } + } + + /** + * Add external JavaScript import metadata to the result. This is currently used + * by the development server to optimize the prebundling process. + * @param implicitBrowser External dependencies for the browser bundles due to the external packages option. + * @param implicitServer External dependencies for the server bundles due to the external packages option. + * @param explicit External dependencies due to explicit project configuration. + */ + setExternalMetadata( + implicitBrowser: string[], + implicitServer: string[], + explicit: string[], + ): void { + this.externalMetadata = { implicitBrowser, implicitServer, explicit }; + } + + get output() { + return { + success: this.errors.length === 0, + }; + } + + get outputWithFiles() { + return { + success: this.errors.length === 0, + outputFiles: this.outputFiles, + assetFiles: this.assetFiles, + errors: this.errors, + externalMetadata: this.externalMetadata, + }; + } + + get watchFiles(): Readonly { + const { typescriptContexts, otherContexts } = this.rebuildContexts; + + return [ + // Bundler contexts internally normalize file dependencies. + ...typescriptContexts.flatMap((context) => [...context.watchFiles]), + ...otherContexts.flatMap((context) => [...context.watchFiles]), + // These files originate from TS/NG and can have POSIX path separators even on Windows. + // To ensure path comparisons are valid, all these paths must be normalized. + ...(this.codeBundleCache?.referencedFiles?.map(normalize) ?? []), + // The assets source files. + ...this.assetFiles.map(({ source }) => source), + ...this.extraWatchFiles, + ]; + } + + createRebuildState(fileChanges: ChangedFiles): RebuildState { + return { + rebuildContexts: this.rebuildContexts, + codeBundleCache: this.codeBundleCache, + componentStyleBundler: this.componentStyleBundler, + fileChanges, + previousOutputInfo: new Map( + this.outputFiles.map(({ path, hash, type }) => [`${type}:${path}`, { hash, type, path }]), + ), + // Tracked by destination since one source can be copied to several outputs (e.g. per locale). + previousAssetsInfo: new Set(this.assetFiles.map(({ destination }) => destination)), + templateUpdates: this.templateUpdates, + }; + } + + findChangedFiles( + previousOutputHashes: ReadonlyMap, + ): Set { + const changed = new Set(); + for (const file of this.outputFiles) { + const previousHash = previousOutputHashes.get(`${file.type}:${file.path}`)?.hash; + if (previousHash === undefined || previousHash !== file.hash) { + changed.add(file.path); + } + } + + return changed; + } + + #disposal?: Promise; + + dispose(): Promise { + return (this.#disposal ??= this.#dispose()); + } + + async #dispose(): Promise { + try { + await Promise.allSettled([ + ...this.rebuildContexts.typescriptContexts.map((context) => context.dispose()), + ...this.rebuildContexts.otherContexts.map((context) => context.dispose()), + this.componentStyleBundler.dispose(), + ]); + } finally { + this.codeBundleCache?.clear(); + } + } +} diff --git a/packages/angular/build/src/tools/esbuild/bundler-files.ts b/packages/angular/build/src/tools/esbuild/bundler-files.ts new file mode 100644 index 000000000000..f69835ab746b --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/bundler-files.ts @@ -0,0 +1,195 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Metafile, OutputFile } from 'esbuild'; +import { calculateHash } from '../../utils/hash'; + +export interface InitialFileRecord { + entrypoint: boolean; + name?: string; + type: 'script' | 'style'; + external?: boolean; + serverFile: boolean; + depth: number; +} + +/** + * Analyzes a metafile to find and record all transitive initial files starting from the given + * initial entries using breadth-first traversal so each file is recorded at its minimum depth. + * + * @param metafile The esbuild metafile containing output import relationships. + * @param initialFiles The map of initial files to populate. + * @param entriesToAnalyze Optional starting entries to traverse; defaults to all current `initialFiles`. + * Entries must share the same starting depth (or be ordered by non-decreasing depth) to guarantee + * that each transitive file is visited at its minimum depth first. + * @param initialFilter Optional filter predicate to determine whether a discovered record should be added. + */ +export function findTransitiveInitialFiles( + metafile: Metafile, + initialFiles: Map, + entriesToAnalyze: Iterable<[string, InitialFileRecord]> = initialFiles, + initialFilter?: (initial: Readonly) => boolean, +): void { + const queue = [...entriesToAnalyze]; + for (let i = 0; i < queue.length; i++) { + const [entryPath, entryRecord] = queue[i]; + const entryOutput = metafile.outputs[entryPath]; + if (!entryOutput) { + continue; + } + + for (const initialImport of entryOutput.imports) { + if (initialFiles.has(initialImport.path)) { + continue; + } + + if (initialImport.kind === 'import-statement' || initialImport.kind === 'import-rule') { + const record: InitialFileRecord = { + type: initialImport.kind === 'import-rule' ? 'style' : 'script', + entrypoint: false, + external: initialImport.external, + serverFile: entryRecord.serverFile, + depth: entryRecord.depth + 1, + }; + + if (!initialFilter || initialFilter(record)) { + initialFiles.set(initialImport.path, record); + + if (!initialImport.external) { + queue.push([initialImport.path, record]); + } + } + } + } + } +} + +export enum BuildOutputFileType { + Browser, + Media, + ServerApplication, + ServerRoot, + Root, +} + +export interface BuildOutputFile extends OutputFile { + type: BuildOutputFileType; + readonly size: number; + clone: () => BuildOutputFile; +} + +export function createOutputFile( + path: string, + data: string | Uint8Array, + type: BuildOutputFileType, +): BuildOutputFile { + if (typeof data === 'string') { + let cachedContents: Uint8Array | null = null; + let cachedText: string | null = data; + let cachedHash: string | null = null; + + return { + path, + type, + get contents(): Uint8Array { + cachedContents ??= new TextEncoder().encode(data); + + return cachedContents; + }, + set contents(value: Uint8Array) { + cachedContents = value; + cachedText = null; + cachedHash = null; + }, + get text(): string { + cachedText ??= new TextDecoder('utf-8').decode(this.contents); + + return cachedText; + }, + get size(): number { + return this.contents.byteLength; + }, + get hash(): string { + cachedHash ??= calculateHash(cachedText ?? this.contents); + + return cachedHash; + }, + clone(): BuildOutputFile { + return createOutputFile(this.path, cachedText ?? this.contents, this.type); + }, + }; + } else { + let cachedContents = data; + let cachedText: string | null = null; + let cachedHash: string | null = null; + + return { + get contents(): Uint8Array { + return cachedContents; + }, + set contents(value: Uint8Array) { + cachedContents = value; + cachedText = null; + cachedHash = null; + }, + path, + type, + get size(): number { + return this.contents.byteLength; + }, + get text(): string { + cachedText ??= new TextDecoder('utf-8').decode(this.contents); + + return cachedText; + }, + get hash(): string { + cachedHash ??= calculateHash(this.contents); + + return cachedHash; + }, + clone(): BuildOutputFile { + return createOutputFile(this.path, this.contents, this.type); + }, + }; + } +} + +export function convertOutputFile(file: OutputFile, type: BuildOutputFileType): BuildOutputFile { + let { contents: cachedContents } = file; + let cachedText: string | null = null; + let cachedHash: string | null = file.hash; + + return { + get contents(): Uint8Array { + return cachedContents; + }, + set contents(value: Uint8Array) { + cachedContents = value; + cachedText = null; + cachedHash = null; + }, + get hash(): string { + cachedHash ??= calculateHash(cachedContents); + + return cachedHash; + }, + path: file.path, + type, + get size(): number { + return this.contents.byteLength; + }, + get text(): string { + cachedText ??= new TextDecoder('utf-8').decode(this.contents); + + return cachedText; + }, + clone(): BuildOutputFile { + return convertOutputFile(this, this.type); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/bundler-files_spec.ts b/packages/angular/build/src/tools/esbuild/bundler-files_spec.ts new file mode 100644 index 000000000000..be21f7ded608 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/bundler-files_spec.ts @@ -0,0 +1,298 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Metafile } from 'esbuild'; +import { type InitialFileRecord, findTransitiveInitialFiles } from './bundler-files'; + +function createMetafile( + outputs: Record< + string, + { + imports: { + path: string; + kind: 'import-statement' | 'import-rule' | 'dynamic-import'; + external?: boolean; + }[]; + } + >, +): Metafile { + return { + inputs: {}, + outputs: Object.fromEntries( + Object.entries(outputs).map(([key, value]) => [ + key, + { + bytes: 0, + inputs: {}, + imports: value.imports, + exports: [], + }, + ]), + ), + }; +} + +describe('findTransitiveInitialFiles', () => { + it('should compute minimum depth and propagate it to transitive children in a DAG', () => { + // main (0) -> [a (1), b (1)] + // b (1) -> c (2) -> d (via b: 3, via a: 2) -> e (should be 3, not 4) + // a (1) -> d (2) + const metafile = createMetafile({ + 'main.js': { + imports: [ + { path: 'chunk-a.js', kind: 'import-statement' }, + { path: 'chunk-b.js', kind: 'import-statement' }, + ], + }, + 'chunk-a.js': { + imports: [{ path: 'chunk-d.js', kind: 'import-statement' }], + }, + 'chunk-b.js': { + imports: [{ path: 'chunk-c.js', kind: 'import-statement' }], + }, + 'chunk-c.js': { + imports: [{ path: 'chunk-d.js', kind: 'import-statement' }], + }, + 'chunk-d.js': { + imports: [{ path: 'chunk-e.js', kind: 'import-statement' }], + }, + 'chunk-e.js': { + imports: [], + }, + }); + + const initialFiles = new Map([ + ['main.js', { entrypoint: true, name: 'main', type: 'script', serverFile: false, depth: 0 }], + ]); + + findTransitiveInitialFiles(metafile, initialFiles); + + expect(initialFiles.get('chunk-a.js')?.depth).toBe(1); + expect(initialFiles.get('chunk-b.js')?.depth).toBe(1); + expect(initialFiles.get('chunk-c.js')?.depth).toBe(2); + expect(initialFiles.get('chunk-d.js')?.depth).toBe(2); + expect(initialFiles.get('chunk-e.js')?.depth).toBe(3); + }); + + it('should record files in breadth-first forward import order', () => { + const metafile = createMetafile({ + 'main.js': { + imports: [ + { path: 'chunk-a.js', kind: 'import-statement' }, + { path: 'chunk-b.js', kind: 'import-statement' }, + ], + }, + 'chunk-a.js': { + imports: [{ path: 'chunk-a1.js', kind: 'import-statement' }], + }, + 'chunk-b.js': { + imports: [{ path: 'chunk-b1.js', kind: 'import-statement' }], + }, + 'chunk-a1.js': { imports: [] }, + 'chunk-b1.js': { imports: [] }, + }); + + const initialFiles = new Map([ + ['main.js', { entrypoint: true, name: 'main', type: 'script', serverFile: false, depth: 0 }], + ]); + + findTransitiveInitialFiles(metafile, initialFiles); + + expect([...initialFiles.keys()]).toEqual([ + 'main.js', + 'chunk-a.js', + 'chunk-b.js', + 'chunk-a1.js', + 'chunk-b1.js', + ]); + }); + + it('should ignore dynamic imports and handle external and style imports', () => { + const metafile = createMetafile({ + 'main.js': { + imports: [ + { path: 'lazy.js', kind: 'dynamic-import' }, + { path: '@angular/core', kind: 'import-statement', external: true }, + ], + }, + 'styles.css': { + imports: [{ path: 'https://example.com/font.css', kind: 'import-rule', external: true }], + }, + 'lazy.js': { + imports: [{ path: 'lazy-dep.js', kind: 'import-statement' }], + }, + }); + + const initialFiles = new Map([ + ['main.js', { entrypoint: true, name: 'main', type: 'script', serverFile: false, depth: 0 }], + [ + 'styles.css', + { entrypoint: true, name: 'styles', type: 'style', serverFile: false, depth: 0 }, + ], + ]); + + findTransitiveInitialFiles(metafile, initialFiles); + + expect(initialFiles.has('lazy.js')).toBeFalse(); + expect(initialFiles.has('lazy-dep.js')).toBeFalse(); + expect(initialFiles.get('@angular/core')).toEqual({ + type: 'script', + entrypoint: false, + external: true, + serverFile: false, + depth: 1, + }); + expect(initialFiles.get('https://example.com/font.css')).toEqual({ + type: 'style', + entrypoint: false, + external: true, + serverFile: false, + depth: 1, + }); + }); + + it('should only traverse from provided entriesToAnalyze while preserving existing initialFiles', () => { + const metafile = createMetafile({ + 'main.js': { + imports: [{ path: 'chunk-main.js', kind: 'import-statement' }], + }, + 'polyfills.js': { + imports: [{ path: 'chunk-polyfills.js', kind: 'import-statement' }], + }, + 'chunk-main.js': { imports: [] }, + 'chunk-polyfills.js': { imports: [] }, + }); + + const mainRecord: InitialFileRecord = { + entrypoint: true, + name: 'main', + type: 'script', + serverFile: false, + depth: 0, + }; + const polyfillsRecord: InitialFileRecord = { + entrypoint: true, + name: 'polyfills', + type: 'script', + serverFile: false, + depth: 0, + }; + const initialFiles = new Map([ + ['main.js', mainRecord], + ['polyfills.js', polyfillsRecord], + ]); + + const entriesToAnalyze: [string, InitialFileRecord][] = [['main.js', mainRecord]]; + findTransitiveInitialFiles(metafile, initialFiles, entriesToAnalyze); + + expect(entriesToAnalyze.length).toBe(1); + expect(initialFiles.has('main.js')).toBeTrue(); + expect(initialFiles.has('polyfills.js')).toBeTrue(); + expect(initialFiles.has('chunk-main.js')).toBeTrue(); + expect(initialFiles.has('chunk-polyfills.js')).toBeFalse(); + }); + + it('should filter discovered records using initialFilter when provided', () => { + const metafile = createMetafile({ + 'main.js': { + imports: [ + { path: 'chunk-a.js', kind: 'import-statement' }, + { path: 'external-lib', kind: 'import-statement', external: true }, + ], + }, + 'chunk-a.js': { + imports: [{ path: 'chunk-b.js', kind: 'import-statement' }], + }, + 'chunk-b.js': { + imports: [{ path: 'chunk-c.js', kind: 'import-statement' }], + }, + 'chunk-c.js': { + imports: [{ path: 'chunk-b.js', kind: 'import-statement' }], + }, + }); + + const initialFiles = new Map([ + ['main.js', { entrypoint: true, name: 'main', type: 'script', serverFile: false, depth: 0 }], + ]); + + findTransitiveInitialFiles( + metafile, + initialFiles, + undefined, + (record) => !record.external && record.depth <= 1, + ); + + expect(initialFiles.has('chunk-a.js')).toBeTrue(); + expect(initialFiles.has('external-lib')).toBeFalse(); + expect(initialFiles.has('chunk-b.js')).toBeFalse(); + expect(initialFiles.has('chunk-c.js')).toBeFalse(); + }); + + it('should handle circular static imports between chunks without infinite recursion', () => { + const metafile = createMetafile({ + 'main.js': { + imports: [{ path: 'chunk-a.js', kind: 'import-statement' }], + }, + 'chunk-a.js': { + imports: [{ path: 'chunk-b.js', kind: 'import-statement' }], + }, + 'chunk-b.js': { + imports: [ + { path: 'chunk-a.js', kind: 'import-statement' }, + { path: 'main.js', kind: 'import-statement' }, + ], + }, + }); + + const initialFiles = new Map([ + ['main.js', { entrypoint: true, name: 'main', type: 'script', serverFile: false, depth: 0 }], + ]); + + findTransitiveInitialFiles(metafile, initialFiles); + + expect(initialFiles.get('main.js')?.depth).toBe(0); + expect(initialFiles.get('chunk-a.js')?.depth).toBe(1); + expect(initialFiles.get('chunk-b.js')?.depth).toBe(2); + }); + + it('should propagate serverFile flag from entry record to transitive imports', () => { + const metafile = createMetafile({ + 'server.mjs': { + imports: [{ path: 'chunk-server-1.mjs', kind: 'import-statement' }], + }, + 'chunk-server-1.mjs': { + imports: [{ path: 'chunk-server-2.mjs', kind: 'import-statement' }], + }, + 'chunk-server-2.mjs': { imports: [] }, + }); + + const initialFiles = new Map([ + [ + 'server.mjs', + { entrypoint: true, name: 'server', type: 'script', serverFile: true, depth: 0 }, + ], + ]); + + findTransitiveInitialFiles(metafile, initialFiles); + + expect(initialFiles.get('chunk-server-1.mjs')).toEqual({ + type: 'script', + entrypoint: false, + external: undefined, + serverFile: true, + depth: 1, + }); + expect(initialFiles.get('chunk-server-2.mjs')).toEqual({ + type: 'script', + entrypoint: false, + external: undefined, + serverFile: true, + depth: 2, + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/commonjs-checker.ts b/packages/angular/build/src/tools/esbuild/commonjs-checker.ts new file mode 100644 index 000000000000..305d6d74a44c --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/commonjs-checker.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Metafile, PartialMessage } from 'esbuild'; + +/** + * Checks the input files of a build to determine if any of the files included + * in the build are not ESM. ESM files can be tree-shaken and otherwise optimized + * in ways that CommonJS and other module formats cannot. The esbuild metafile + * information is used as the basis for the analysis as it contains information + * for each input file including its respective format. + * + * If any allowed dependencies are provided via the `allowedCommonJsDependencies` + * parameter, both the direct import and any deep imports will be ignored and no + * diagnostic will be generated. Use `'*'` as entry to skip the check. + * + * If a module has been issued a diagnostic message, then all descendant modules + * will not be checked. This prevents a potential massive amount of inactionable + * messages since the initial module import is the cause of the problem. + * + * @param metafile An esbuild metafile object to check. + * @param allowedCommonJsDependencies An optional list of allowed dependencies. + * @returns Zero or more diagnostic messages for any non-ESM modules. + */ +export function checkCommonJSModules( + metafile: Metafile, + allowedCommonJsDependencies?: string[], +): PartialMessage[] { + const messages: PartialMessage[] = []; + const allowedRequests = new Set(allowedCommonJsDependencies); + + if (allowedRequests.has('*')) { + return messages; + } + + // Ignore Angular locale definitions which are currently UMD + allowedRequests.add('@angular/common/locales'); + + // Ignore zone.js due to it currently being built with a UMD like structure. + // Once the build output is updated to be fully ESM, this can be removed. + allowedRequests.add('zone.js'); + + // Used by '@angular/platform-server' and is in a seperate chunk that is unused when + // using `provideHttpClient(withFetch())`. + allowedRequests.add('xhr2'); + + // Packages used by @angular/ssr. + // While beasties is ESM it has a number of direct and transtive CJS deps. + allowedRequests.add('express'); + allowedRequests.add('beasties'); + + // Find all entry points that contain code (JS/TS) + const files: string[] = []; + for (const { entryPoint } of Object.values(metafile.outputs)) { + if (!entryPoint) { + continue; + } + if (!isPathCode(entryPoint)) { + continue; + } + + files.push(entryPoint); + } + + // Track seen files so they are only analyzed once. + // Bundler runtime code is also ignored since it cannot be actionable. + const seenFiles = new Set(['']); + + // Analyze the files present by walking the import graph + let currentFile: string | undefined; + while ((currentFile = files.shift())) { + const input = metafile.inputs[currentFile]; + + for (const imported of input.imports) { + // Ignore imports that were already seen or not originally in the code (bundler injected) + if (!imported.original || seenFiles.has(imported.path)) { + continue; + } + + seenFiles.add(imported.path); + + // If the dependency is allowed ignore all other checks + if (allowedRequests.has(imported.original)) { + continue; + } + + // Only check actual code files + if (!isPathCode(imported.path)) { + continue; + } + + // Check if non-relative import is ESM format and issue a diagnostic if the file is not allowed + if ( + !isPotentialRelative(imported.original) && + metafile.inputs[imported.path].format !== 'esm' + ) { + const request = imported.original; + + let notAllowed = true; + if (allowedRequests.has(request)) { + notAllowed = false; + } else { + // Check for deep imports of allowed requests + for (const allowed of allowedRequests) { + if (request.startsWith(allowed + '/')) { + notAllowed = false; + break; + } + } + } + + if (notAllowed) { + // Issue a diagnostic message for CommonJS module + messages.push(createCommonJSModuleError(request, currentFile)); + } + + // Skip all descendants since they are also most likely not ESM but solved by addressing this import + continue; + } + + // Add the path so that its imports can be checked + files.push(imported.path); + } + } + + return messages; +} + +/** + * Determines if a file path has an extension that is a JavaScript or TypeScript + * code file. + * + * @param name A path to check for code file extensions. + * @returns True, if a code file path; false, otherwise. + */ +function isPathCode(name: string): boolean { + return /\.[cm]?[jt]sx?$/.test(name); +} + +/** + * Test an import module specifier to determine if the string potentially references a relative file. + * npm packages should not start with a period so if the first character is a period than it is not a + * package. While this is sufficient for the use case in the CommmonJS checker, only checking the + * first character does not definitely indicate the specifier is a relative path. + * + * @param specifier An import module specifier. + * @returns True, if specifier is potentially relative; false, otherwise. + */ +function isPotentialRelative(specifier: string): boolean { + return specifier[0] === '.'; +} + +/** + * Creates an esbuild diagnostic message for a given non-ESM module request. + * + * @param request The requested non-ESM module name. + * @param importer The path of the file containing the import. + * @returns A message representing the diagnostic. + */ +function createCommonJSModuleError(request: string, importer: string): PartialMessage { + const error = { + text: `Module '${request}' used by '${importer}' is not ESM`, + notes: [ + { + text: + 'CommonJS or AMD dependencies can cause optimization bailouts.\n' + + 'For more information see: https://angular.dev/tools/cli/build#configuring-commonjs-dependencies', + }, + ], + }; + + return error; +} diff --git a/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts b/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts new file mode 100644 index 000000000000..573b8616da60 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import type { createCompilerPlugin } from './angular/compiler-plugin'; +import type { SourceFileCache } from './angular/source-file-cache'; +import type { LoadResultCache } from './load-result-cache'; + +type CreateCompilerPluginParameters = Parameters; + +export function createCompilerPluginOptions( + options: NormalizedApplicationBuildOptions, + sourceFileCache: SourceFileCache, + loadResultCache?: LoadResultCache, + templateUpdates?: Map, +): CreateCompilerPluginParameters[0] { + const { + sourcemapOptions, + tsconfig, + fileReplacements, + advancedOptimizations, + jit, + externalRuntimeStyles, + instrumentForCoverage, + optimizationOptions, + rootFiles, + } = options; + const incremental = !!options.watch; + + return { + sourcemap: !!sourcemapOptions.scripts && (sourcemapOptions.hidden ? 'external' : true), + thirdPartySourcemaps: sourcemapOptions.vendor, + tsconfig, + jit, + advancedOptimizations, + fileReplacements, + sourceFileCache, + loadResultCache, + incremental, + externalRuntimeStyles, + instrumentForCoverage, + templateUpdates, + includeTestMetadata: !optimizationOptions.scripts, + rootFiles, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/external-packages-plugin.ts b/packages/angular/build/src/tools/esbuild/external-packages-plugin.ts new file mode 100644 index 000000000000..b968493005ac --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/external-packages-plugin.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Plugin } from 'esbuild'; +import { extname } from 'node:path'; + +const EXTERNAL_PACKAGE_RESOLUTION = Symbol('EXTERNAL_PACKAGE_RESOLUTION'); + +/** + * Creates a plugin that marks any resolved path as external if it is within a node modules directory. + * This is used instead of the esbuild `packages` option to avoid marking files that should be loaded + * via customized loaders. This is necessary to prevent Vite development server pre-bundling errors. + * + * @returns An esbuild plugin. + */ +export function createExternalPackagesPlugin(options?: { exclude?: string[] }): Plugin { + const exclusions = new Set(options?.exclude); + // Similar to esbuild, --external:@foo/bar automatically implies --external:@foo/bar/*, + // which matches import paths like @foo/bar/baz. + // This means all paths within the @foo/bar package are also marked as external. + const exclusionsPrefixes = options?.exclude?.map((exclusion) => exclusion + '/') ?? []; + const seenExclusions: Set = new Set(); + const seenExternals = new Set(); + const seenNonExclusions: Set = new Set(); + + return { + name: 'angular-external-packages', + setup(build) { + // Find all loader keys that are not using the 'file' loader. + // The 'file' loader is automatically handled by Vite and does not need exclusion. + const loaderOptionKeys = + build.initialOptions.loader && + Object.entries(build.initialOptions.loader) + .filter(([, value]) => value !== 'file') + .map(([key]) => key); + + // Safe to use native packages external option if no loader options or exclusions present + if (!exclusions.size && !loaderOptionKeys?.length) { + build.initialOptions.packages = 'external'; + + return; + } + + const loaderFileExtensions = new Set(loaderOptionKeys); + + // Only attempt resolve of non-relative and non-absolute paths + build.onResolve({ filter: /^[^./]/ }, async (args) => { + if (args.pluginData?.[EXTERNAL_PACKAGE_RESOLUTION]) { + return null; + } + + if (seenExternals.has(args.path)) { + return { external: true }; + } + + if (exclusions.has(args.path) || seenExclusions.has(args.path)) { + return null; + } + + if (!seenNonExclusions.has(args.path)) { + for (const exclusion of exclusionsPrefixes) { + if (args.path.startsWith(exclusion)) { + seenExclusions.add(args.path); + + return null; + } + } + + seenNonExclusions.add(args.path); + } + + const { importer, kind, resolveDir, namespace, pluginData = {} } = args; + pluginData[EXTERNAL_PACKAGE_RESOLUTION] = true; + + const result = await build.resolve(args.path, { + importer, + kind, + namespace, + pluginData, + resolveDir, + }); + + // Return result if unable to resolve + if (!result.path) { + return result; + } + + // Return if explicitly marked external (externalDependencies option) + if (result.external) { + seenExternals.add(args.path); + + return { external: true }; + } + + // Allow customized loaders to run against configured paths regardless of location + if (loaderFileExtensions.has(extname(result.path))) { + return result; + } + + // Mark paths from a node modules directory as external + if (/[\\/]node_modules[\\/]/.test(result.path)) { + seenExternals.add(args.path); + + return { external: true }; + } + + // Otherwise return original result + return result; + }); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/global-scripts.ts b/packages/angular/build/src/tools/esbuild/global-scripts.ts new file mode 100644 index 000000000000..2b6c581a572f --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/global-scripts.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Bundle, MagicString } from 'magic-string'; +import assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import { assertIsError } from '../../utils/error'; +import { BundlerOptionsFactory } from './bundler-context'; +import { createCachedLoad } from './load-result-cache'; +import { createSourcemapIgnorelistPlugin } from './sourcemap-ignorelist-plugin'; +import { createVirtualModulePlugin } from './virtual-module-plugin'; + +/** + * Create an esbuild 'build' options object for all global scripts defined in the user provied + * build options. + * @param options The builder's user-provider normalized options. + * @returns An esbuild BuildOptions object. + */ +export function createGlobalScriptsBundleOptions( + options: NormalizedApplicationBuildOptions, + target: string[], + initial: boolean, +): BundlerOptionsFactory | undefined { + const { + globalScripts, + optimizationOptions, + outputNames, + preserveSymlinks, + sourcemapOptions, + jsonLogs, + workspaceRoot, + define, + } = options; + + const namespace = 'angular:script/global'; + const entryPoints: Record = {}; + let found = false; + for (const script of globalScripts) { + if (script.initial === initial) { + found = true; + entryPoints[script.name] = `${namespace}:${script.name}`; + } + } + + // Skip if there are no entry points for the style loading type + if (found === false) { + return; + } + + return (loadCache) => { + return { + absWorkingDir: workspaceRoot, + bundle: false, + splitting: false, + entryPoints, + entryNames: initial ? outputNames.bundles : '[name]', + assetNames: outputNames.media, + mainFields: ['script', 'browser', 'main'], + conditions: ['script', optimizationOptions.scripts ? 'production' : 'development'], + resolveExtensions: ['.mjs', '.js', '.cjs'], + logLevel: options.verbose && !jsonLogs ? 'debug' : 'silent', + metafile: true, + minify: optimizationOptions.scripts, + outdir: workspaceRoot, + sourcemap: sourcemapOptions.scripts && (sourcemapOptions.hidden ? 'external' : true), + write: false, + platform: 'neutral', + target, + preserveSymlinks, + define, + plugins: [ + createSourcemapIgnorelistPlugin(), + createVirtualModulePlugin({ + namespace, + external: true, + // Add the `js` extension here so that esbuild generates an output file with the extension + transformPath: (path) => path.slice(namespace.length + 1) + '.js', + loadContent: (args, build) => + createCachedLoad(loadCache, async (args) => { + const files = globalScripts.find( + ({ name }) => name === args.path.slice(0, -3), + )?.files; + assert(files, `Invalid operation: global scripts name not found [${args.path}]`); + + // Global scripts are concatenated using magic-string instead of bundled via esbuild. + const bundleContent = new Bundle(); + const watchFiles = []; + for (const filename of files) { + let fileContent; + try { + // Attempt to read as a relative path from the workspace root + const fullPath = path.join(workspaceRoot, filename); + fileContent = await readFile(fullPath, 'utf-8'); + watchFiles.push(fullPath); + } catch (e) { + assertIsError(e); + if (e.code !== 'ENOENT') { + throw e; + } + + // If not found, attempt to resolve as a module specifier + const resolveResult = await build.resolve(filename, { + kind: 'entry-point', + resolveDir: workspaceRoot, + }); + + if (resolveResult.errors.length) { + // Remove resolution failure notes about marking as external since it doesn't apply + // to global scripts. + resolveResult.errors.forEach((error) => (error.notes = [])); + + return { + errors: resolveResult.errors, + warnings: resolveResult.warnings, + }; + } + + watchFiles.push(resolveResult.path); + fileContent = await readFile(resolveResult.path, 'utf-8'); + } + + bundleContent.addSource(new MagicString(fileContent, { filename })); + } + + return { + contents: bundleContent.toString(), + loader: 'js', + watchFiles, + }; + }).call(build, args), + }), + ], + }; + }; +} diff --git a/packages/angular/build/src/tools/esbuild/global-styles.ts b/packages/angular/build/src/tools/esbuild/global-styles.ts new file mode 100644 index 000000000000..79afff2008af --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/global-styles.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import { toPosixPath } from '../../utils/path'; +import { BundlerOptionsFactory } from './bundler-context'; +import { createStylesheetBundleOptions } from './stylesheets/bundle-options'; +import { createVirtualModulePlugin } from './virtual-module-plugin'; + +export function createGlobalStylesBundleOptions( + options: NormalizedApplicationBuildOptions, + target: string[], + initial: boolean, +): BundlerOptionsFactory | undefined { + const { + workspaceRoot, + optimizationOptions, + sourcemapOptions, + outputNames, + globalStyles, + preserveSymlinks, + externalDependencies, + stylePreprocessorOptions, + tailwindConfiguration, + postcssConfiguration, + cacheOptions, + } = options; + + const namespace = 'angular:styles/global'; + const entryPoints: Record = {}; + let found = false; + for (const style of globalStyles) { + if (style.initial === initial) { + found = true; + entryPoints[style.name] = `${namespace};${style.name}`; + } + } + + // Skip if there are no entry points for the style loading type + if (found === false) { + return; + } + + return (loadCache) => { + const buildOptions = createStylesheetBundleOptions( + { + workspaceRoot, + optimization: !!optimizationOptions.styles.minify, + inlineFonts: !!optimizationOptions.fonts.inline, + sourcemap: !!sourcemapOptions.styles && (sourcemapOptions.hidden ? 'external' : true), + sourcesContent: sourcemapOptions.sourcesContent, + preserveSymlinks, + target, + externalDependencies, + outputNames: initial + ? outputNames + : { + ...outputNames, + bundles: '[name]', + }, + includePaths: stylePreprocessorOptions?.includePaths, + // string[] | undefined' is not assignable to type '(Version | DeprecationOrId)[] | undefined'. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sass: stylePreprocessorOptions?.sass as any, + tailwindConfiguration, + postcssConfiguration, + cacheOptions, + }, + loadCache, + ); + + // Keep special CSS comments `/*! comment */` in place when `removeSpecialComments` is disabled. + // These comments are special for a number of CSS tools such as Beasties and PurgeCSS. + buildOptions.legalComments = optimizationOptions.styles?.removeSpecialComments + ? 'none' + : 'inline'; + + buildOptions.entryPoints = entryPoints; + + buildOptions.plugins.unshift( + createVirtualModulePlugin({ + namespace, + transformPath: (path) => path.split(';', 2)[1], + loadContent: (args) => { + const files = globalStyles.find(({ name }) => name === args.path)?.files; + assert(files, `global style name should always be found [${args.path}]`); + + return { + contents: files + .map((file) => `@import ${JSON.stringify(toPosixPath(file))};`) + .join('\n'), + loader: 'css', + resolveDir: workspaceRoot, + }; + }, + }), + ); + + return buildOptions; + }; +} diff --git a/packages/angular/build/src/tools/esbuild/i18n-locale-plugin.ts b/packages/angular/build/src/tools/esbuild/i18n-locale-plugin.ts new file mode 100644 index 000000000000..b4c89cf644c1 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/i18n-locale-plugin.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Plugin } from 'esbuild'; +import { createProjectResolver } from '../../utils/resolve-project'; +import { + LOCALE_DATA_NAMESPACE, + type LoadedLocaleData, + type LocaleDataResolution, + loadLocaleData, + resolveLocaleDataPath, +} from '../i18n/locale-data'; + +export { + LOCALE_DATA_NAMESPACE, + LOCALE_DATA_BASE_MODULE, + type LoadedLocaleData, + type LocaleDataResolution, + loadLocaleData, + resolveLocaleDataPath, +} from '../i18n/locale-data'; + +/** + * Creates an esbuild plugin that resolves Angular locale data files from `@angular/common`. + * + * @returns An esbuild plugin. + */ +export function createAngularLocaleDataPlugin(): Plugin { + return { + name: 'angular-locale-data', + setup(build): void { + build.onResolve({ filter: /^angular:locale\/data:/ }, async ({ path }) => { + const rawLocaleTag = path.split(':', 3)[2]; + const { absWorkingDir } = build.initialOptions; + let projectResolve: ((packageName: string) => string) | undefined; + + const resolution = resolveLocaleDataPath(rawLocaleTag, (potentialPath) => { + projectResolve ??= createProjectResolver(absWorkingDir ?? process.cwd()); + try { + return projectResolve(potentialPath); + } catch { + return undefined; + } + }); + + if (resolution.error) { + return { + path: rawLocaleTag, + namespace: LOCALE_DATA_NAMESPACE, + errors: [{ text: resolution.error }], + }; + } + + if (!resolution.path) { + return { + path: rawLocaleTag, + namespace: LOCALE_DATA_NAMESPACE, + warnings: resolution.warning + ? [{ location: null, text: resolution.warning }] + : undefined, + }; + } + + return { + path: resolution.path, + warnings: resolution.warning ? [{ location: null, text: resolution.warning }] : undefined, + }; + }); + + // Locales that cannot be found or are en/en-US will be loaded as empty content + build.onLoad({ filter: /./, namespace: LOCALE_DATA_NAMESPACE }, () => ({ + contents: '', + loader: 'empty', + })); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/license-extractor.ts b/packages/angular/build/src/tools/esbuild/license-extractor.ts new file mode 100644 index 000000000000..4c73012033bb --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/license-extractor.ts @@ -0,0 +1,193 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Metafile } from 'esbuild'; +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; + +/** + * The path segment used to signify that a file is part of a package. + */ +const NODE_MODULE_SEGMENT = 'node_modules'; + +/** + * String constant for the NPM recommended custom license wording. + * + * See: https://docs.npmjs.com/cli/v9/configuring-npm/package-json#license + * + * Example: + * ``` + * { + * "license" : "SEE LICENSE IN " + * } + * ``` + */ +const CUSTOM_LICENSE_TEXT = 'SEE LICENSE IN '; + +/** + * A regular expression for commonly named license files found within packages. + */ +const LICENSE_FILE_REGEXP = /^(?:mit-)?licen[cs]e(?:$|[-._])/i; + +/** + * Header text that will be added to the top of the output license extraction file. + */ +const EXTRACTION_FILE_HEADER = ''; + +/** + * The package entry separator to use within the output license extraction file. + */ +const EXTRACTION_FILE_SEPARATOR = '-'.repeat(80) + '\n'; + +/** + * Extracts license information for each node module package included in the output + * files of the built code. This includes JavaScript and CSS output files. The esbuild + * metafile generated during the bundling steps is used as the source of information + * regarding what input files where included and where they are located. A path segment + * of `node_modules` is used to indicate that a file belongs to a package and its license + * should be include in the output licenses file. + * + * The package name and license field are extracted from the `package.json` file for the + * package. If a license file (e.g., `LICENSE`) is present in the root of the package, it + * will also be included in the output licenses file. + * + * @param metafile An esbuild metafile object. + * @param rootDirectory The root directory of the workspace. + * @returns A string containing the content of the output licenses file. + */ +export async function extractLicenses( + metafiles: Metafile[], + rootDirectory: string, +): Promise { + let extractedLicenseContent = `${EXTRACTION_FILE_HEADER}\n${EXTRACTION_FILE_SEPARATOR}`; + + const seenPaths = new Set(); + const seenPackageDirectories = new Set(); + const seenPackages = new Set(); + + for (const metafile of metafiles) { + for (const entry of Object.values(metafile.outputs)) { + for (const [inputPath, { bytesInOutput }] of Object.entries(entry.inputs)) { + // Skip if not included in output + if (bytesInOutput <= 0) { + continue; + } + + // Skip already processed paths + if (seenPaths.has(inputPath)) { + continue; + } + seenPaths.add(inputPath); + + // Skip non-package paths + if (!inputPath.includes(NODE_MODULE_SEGMENT)) { + continue; + } + + // Extract the package name from the path + let baseDirectory = path.join(rootDirectory, inputPath); + let nameOrScope, nameOrFile; + let found = false; + while (baseDirectory !== path.dirname(baseDirectory)) { + const segment = path.basename(baseDirectory); + if (segment === NODE_MODULE_SEGMENT) { + found = true; + break; + } + + nameOrFile = nameOrScope; + nameOrScope = segment; + baseDirectory = path.dirname(baseDirectory); + } + + // Skip non-package path edge cases that are not caught in the includes check above + if (!found || !nameOrScope) { + continue; + } + + const packageName = nameOrScope.startsWith('@') + ? `${nameOrScope}/${nameOrFile}` + : nameOrScope; + const packageDirectory = path.join(baseDirectory, packageName); + + if (seenPackageDirectories.has(packageDirectory)) { + continue; + } + seenPackageDirectories.add(packageDirectory); + + // Load the package's metadata to find the package's name, version, and license type + const packageJsonPath = path.join(packageDirectory, 'package.json'); + let packageJson; + try { + packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8')) as { + name: string; + version: string; + // The object form is deprecated and should only be present in old packages + license?: string | { type: string }; + }; + } catch { + // Invalid package + continue; + } + + // Skip already processed packages + const packageId = `${packageName}@${packageJson.version}`; + if (seenPackages.has(packageId)) { + continue; + } + seenPackages.add(packageId); + + // Attempt to find license text inside package + let licenseText = ''; + if ( + typeof packageJson.license === 'string' && + packageJson.license.toUpperCase().startsWith(CUSTOM_LICENSE_TEXT) + ) { + // Attempt to load the package's custom license + let customLicensePath; + const customLicenseFile = path.normalize( + packageJson.license.slice(CUSTOM_LICENSE_TEXT.length).trim(), + ); + if (customLicenseFile.startsWith('..') || path.isAbsolute(customLicenseFile)) { + // Path is attempting to access files outside of the package + // TODO: Issue warning? + } else { + customLicensePath = path.join(packageDirectory, customLicenseFile); + try { + licenseText = await readFile(customLicensePath, 'utf-8'); + } catch {} + } + } else { + // Search for a license file within the root of the package + const entries = await readdir(packageDirectory, { withFileTypes: true }).catch(() => []); + + for (const entry of entries) { + if ( + (entry.isFile() || entry.isSymbolicLink()) && + LICENSE_FILE_REGEXP.test(entry.name) + ) { + const packageLicensePath = path.join(packageDirectory, entry.name); + try { + licenseText = await readFile(packageLicensePath, 'utf-8'); + break; + } catch {} + } + } + } + + // Generate the package's license entry in the output content + extractedLicenseContent += `Package: ${packageJson.name}\n`; + extractedLicenseContent += `License: ${JSON.stringify(packageJson.license, null, 2)}\n`; + extractedLicenseContent += `\n${licenseText}\n`; + extractedLicenseContent += EXTRACTION_FILE_SEPARATOR; + } + } + } + + return extractedLicenseContent; +} diff --git a/packages/angular/build/src/tools/esbuild/load-result-cache.ts b/packages/angular/build/src/tools/esbuild/load-result-cache.ts new file mode 100644 index 000000000000..402069cff1a5 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/load-result-cache.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { OnLoadResult, PluginBuild } from 'esbuild'; +import { normalize } from 'node:path'; + +export interface LoadResultCache { + get(path: string): OnLoadResult | Promise | undefined; + put(path: string, result: OnLoadResult): Promise; + invalidate(path: string): boolean; + readonly watchFiles: ReadonlyArray; +} + +export function createCachedLoad( + cache: LoadResultCache | undefined, + callback: Parameters[1], +): Parameters[1] { + if (cache === undefined) { + return callback; + } + + return async (args) => { + const loadCacheKey = `${args.namespace}:${args.path}`; + let result: OnLoadResult | null | undefined = await cache.get(loadCacheKey); + + if (result === undefined) { + result = await callback(args); + + // Do not cache null or undefined + if (result) { + // Ensure requested path is included if it was a resolved file + if (args.namespace === 'file') { + result.watchFiles ??= []; + if (!result.watchFiles.includes(args.path)) { + result.watchFiles.push(args.path); + } + } + await cache.put(loadCacheKey, result); + } + } + + return result; + }; +} + +export class MemoryLoadResultCache implements LoadResultCache { + #loadResults = new Map(); + #fileDependencies = new Map>(); + #watchFilesPerKey = new Map>(); + + get(path: string): OnLoadResult | undefined { + return this.#loadResults.get(path); + } + + async put(path: string, result: OnLoadResult): Promise { + const previousWatchFiles = this.#watchFilesPerKey.get(path); + + if (result.errors && result.errors.length > 0) { + if (previousWatchFiles) { + result.watchFiles = Array.from( + new Set([...(result.watchFiles ?? []), ...previousWatchFiles]), + ); + } + } + + const currentNormalizedWatchFiles = new Set(result.watchFiles?.map(normalize) ?? []); + + // Clean up any previous file dependencies that are no longer referenced + if (previousWatchFiles) { + for (const watchFile of previousWatchFiles) { + const normalizedWatchFile = normalize(watchFile); + if (!currentNormalizedWatchFiles.has(normalizedWatchFile)) { + const affected = this.#fileDependencies.get(normalizedWatchFile); + if (affected) { + affected.delete(path); + if (affected.size === 0) { + this.#fileDependencies.delete(normalizedWatchFile); + } + } + } + } + } + + if (result.watchFiles && result.watchFiles.length > 0) { + this.#watchFilesPerKey.set(path, [...result.watchFiles]); + } else { + this.#watchFilesPerKey.delete(path); + } + + this.#loadResults.set(path, result); + if (result.watchFiles) { + for (const watchFile of result.watchFiles) { + // Normalize the watch file path to ensure OS consistent paths + const normalizedWatchFile = normalize(watchFile); + let affected = this.#fileDependencies.get(normalizedWatchFile); + if (affected === undefined) { + affected = new Set(); + this.#fileDependencies.set(normalizedWatchFile, affected); + } + affected.add(path); + } + } + } + + invalidate(path: string): boolean { + const affectedPaths = this.#fileDependencies.get(path); + if (!affectedPaths) { + return false; + } + + for (const affected of affectedPaths) { + this.#loadResults.delete(affected); + } + + return true; + } + + get watchFiles(): string[] { + // this.#loadResults.keys() is not included here because the keys + // are namespaced request paths and not disk-based file paths. + return [...this.#fileDependencies.keys()]; + } + + clear(): void { + this.#loadResults.clear(); + this.#fileDependencies.clear(); + this.#watchFilesPerKey.clear(); + } +} diff --git a/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts b/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts new file mode 100644 index 000000000000..8a8682beed61 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { MemoryLoadResultCache } from './load-result-cache'; + +describe('MemoryLoadResultCache', () => { + let cache: MemoryLoadResultCache; + + beforeEach(() => { + cache = new MemoryLoadResultCache(); + }); + + it('should store and retrieve results', async () => { + const result = { + contents: 'body { color: red; }', + loader: 'css' as const, + }; + + await cache.put('file:/test/styles.css', result); + const cached = cache.get('file:/test/styles.css'); + + expect(cached).toBe(result); + }); + + it('should track watch files in fileDependencies', async () => { + const result = { + contents: 'body { color: red; }', + loader: 'css' as const, + watchFiles: ['/test/styles.css', '/test/theme.json'], + }; + + await cache.put('file:/test/styles.css', result); + + expect(cache.watchFiles).toContain('/test/styles.css'); + expect(cache.watchFiles).toContain('/test/theme.json'); + }); + + it('should invalidate cached results when a dependency changes', async () => { + const result = { + contents: 'body { color: red; }', + loader: 'css' as const, + watchFiles: ['/test/styles.css', '/test/theme.json'], + }; + + await cache.put('file:/test/styles.css', result); + expect(cache.get('file:/test/styles.css')).toBe(result); + + const invalidated = cache.invalidate('/test/theme.json'); + expect(invalidated).toBeTrue(); + expect(cache.get('file:/test/styles.css')).toBeUndefined(); + }); + + it('should preserve previous watch files when caching an error result', async () => { + const successResult = { + contents: 'body { color: red; }', + loader: 'css' as const, + watchFiles: ['/test/styles.css', '/test/theme.json'], + }; + + await cache.put('file:/test/styles.css', successResult); + cache.invalidate('/test/theme.json'); + + // Simulate an incremental rebuild error result that only has the entry file in watchFiles + const errorResult = { + errors: [{ text: 'Syntax error in theme.json' }], + watchFiles: ['/test/styles.css'], + }; + + await cache.put('file:/test/styles.css', errorResult); + + // Both the entry file and the previous dependency should be tracked + expect(cache.watchFiles).toContain('/test/styles.css'); + expect(cache.watchFiles).toContain('/test/theme.json'); + + // Invalidating the dependency should clear the cached error result + const invalidated = cache.invalidate('/test/theme.json'); + expect(invalidated).toBeTrue(); + expect(cache.get('file:/test/styles.css')).toBeUndefined(); + }); + + it('should clean up previous file dependencies when put updates watchFiles', async () => { + const initialResult = { + contents: 'body { color: red; }', + loader: 'css' as const, + watchFiles: ['/test/styles.css', '/test/old-dep.json'], + }; + + await cache.put('file:/test/styles.css', initialResult); + expect(cache.watchFiles).toContain('/test/old-dep.json'); + + // Update with new watch files (not containing old-dep.json) + const updatedResult = { + contents: 'body { color: blue; }', + loader: 'css' as const, + watchFiles: ['/test/styles.css', '/test/new-dep.json'], + }; + + await cache.put('file:/test/styles.css', updatedResult); + expect(cache.watchFiles).not.toContain('/test/old-dep.json'); + expect(cache.watchFiles).toContain('/test/new-dep.json'); + + // Invalidating old dependency should not affect the cache + expect(cache.invalidate('/test/old-dep.json')).toBeFalse(); + expect(cache.get('file:/test/styles.css')).toBe(updatedResult); + + // Invalidating new dependency should invalidate the cache + expect(cache.invalidate('/test/new-dep.json')).toBeTrue(); + expect(cache.get('file:/test/styles.css')).toBeUndefined(); + // Invalidating a file marks its cached results stale, but preserves watch file tracking + // so the file watcher continues monitoring the dependency for subsequent changes until + // a new build pass (via put) updates the active dependencies. + expect(cache.watchFiles).toContain('/test/new-dep.json'); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/loader-import-attribute-plugin.ts b/packages/angular/build/src/tools/esbuild/loader-import-attribute-plugin.ts new file mode 100644 index 000000000000..8a9588b63a7b --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/loader-import-attribute-plugin.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Loader, Plugin } from 'esbuild'; +import { readFile } from 'node:fs/promises'; + +const SUPPORTED_LOADERS: Loader[] = ['base64', 'binary', 'dataurl', 'file', 'text']; + +export function createLoaderImportAttributePlugin(): Plugin { + return { + name: 'angular-loader-import-attributes', + setup(build) { + build.onLoad({ filter: /./ }, async (args) => { + const loader = args.with['loader'] as Loader | undefined; + if (!loader) { + return undefined; + } + + if (!SUPPORTED_LOADERS.includes(loader)) { + return { + errors: [ + { + text: 'Unsupported loader import attribute', + notes: [ + { text: 'Attribute value must be one of: ' + SUPPORTED_LOADERS.join(', ') }, + ], + }, + ], + }; + } + + return { + contents: await readFile(args.path), + loader, + }; + }); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts new file mode 100644 index 000000000000..7eaf11806f1d --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts @@ -0,0 +1,325 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview + * Implements a generic, two-tier (L1 memory + L2 persistent disk store) caching system for + * esbuild plugin `OnLoadResult` outcomes across the `@angular/build` compiler pipeline. + * + * Supported Module Types: + * 1. **Disk File Modules** (`file:` namespace): Standard disk-based source files (TS, JS, CSS, Sass, Less). + * Cache keys are computed using the root `globalConfigHash`, file path, and file content. Validity is + * verified via fast-path metadata (`mtimeMs` + `size`) with fallback to content hashing (`sha256`). + * 2. **Custom Plugin Namespace Modules** (e.g. `angular:script/global`, `sass:`): Modules loaded through + * custom esbuild namespaces that resolve to disk source files. + * 3. **Virtual Modules & Remote Resources** (e.g. `angular:styles/component`, `css-inline-fonts`): Synthetic + * in-memory modules or remote asset declarations whose compiled outcomes depend on parent source file + * dependencies (`watchFiles`) or global configuration options (`globalConfigHash`). + * + * Key Exported Types: + * - {@link PersistentLoadResultCache}: Primary two-tier cache manager implementing `LoadResultCache`. + * - {@link CachedLoadResultEntry}: Serialized structure persisted to disk for cached esbuild `OnLoadResult` items. + * - {@link CachedDependencyMetadata}: Per-dependency file metadata (`hash`, `mtimeMs`, `size`) used for cache validation and healing. + */ + +import type { Loader, OnLoadResult, PartialMessage } from 'esbuild'; +import { readFile, stat } from 'node:fs/promises'; +import { isAbsolute } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Cache as PersistentCacheStore } from '../../utils/cache'; +import { MAX_CONCURRENT_READS, mapConcurrent, runConcurrent } from '../../utils/concurrency'; +import { calculateHash, createContentHash } from '../../utils/hash'; +import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache'; + +/** + * Metadata for a single watch file dependency. + */ +export interface CachedDependencyMetadata { + hash: string; + mtimeMs: number; + size: number; +} + +/** + * Serialized representation of any esbuild load result stored in persistent cache. + */ +export interface CachedLoadResultEntry { + /** Compiled output string or binary data */ + contents: string | Uint8Array; + + /** esbuild loader type */ + loader?: Loader; + + /** Absolute paths of all imported/watched dependency files */ + watchFiles: string[]; + + /** Absolute paths of all watched directories */ + watchDirs?: string[]; + + /** Map of watchFile absolute paths to dependency metadata */ + watchFilesMetadata: Record; + + /** Warnings emitted during load processing */ + warnings?: PartialMessage[]; + + /** Errors emitted during load processing */ + errors?: PartialMessage[]; +} + +/** + * Calculates a unique cache key from the global configuration hash and path. + */ +function calculateCacheKey(globalConfigHash: string, path: string): string { + const hasher = createContentHash(); + hasher.update(globalConfigHash); + hasher.update('\0'); + hasher.update(path); + + return hasher.digest(); +} + +/** + * Normalizes a namespaced cache key into a valid disk file path if one exists. + * Handles 'file:' URIs, OS platform differences, and custom plugin namespaces. + */ +export function extractDiskFilePath(path: string): string | undefined { + if (path.startsWith('file:')) { + const urlStr = path.startsWith('file://') ? path : 'file://' + path.slice(5); + try { + return fileURLToPath(urlStr); + } catch { + const candidate = path.slice(5); + + return isAbsolute(candidate) ? candidate : undefined; + } + } + + // Handle custom namespace prefix (e.g. "sass:/path/to/file") + // Ensure colonIndex > 1 to avoid treating Windows drive letters (e.g. "C:/") as namespace prefixes. + const colonIndex = path.indexOf(':'); + if (colonIndex > 1) { + const candidatePath = path.slice(colonIndex + 1); + if (isAbsolute(candidatePath)) { + return candidatePath; + } + } + + return isAbsolute(path) ? path : undefined; +} + +/** + * Validates that all imported watch files exist on disk and their contents match. + * Performs a fast-path metadata check (mtime + size) first, falling back to content hashing. + * Heals/updates the cached metadata on disk if the content hash was valid but the metadata changed. + */ +async function validateAndHealCacheEntry( + watchFilesMetadata: Record | undefined, + store: PersistentCacheStore, + cacheKey: string, + cached: CachedLoadResultEntry, +): Promise { + if (!watchFilesMetadata) { + return false; + } + + const watchFiles = Object.keys(watchFilesMetadata); + let healed = false; + + const isValidResults = await mapConcurrent(watchFiles, MAX_CONCURRENT_READS, async (filePath) => { + try { + const stats = await stat(filePath); + const expected = watchFilesMetadata[filePath]; + if (!expected) { + return false; + } + + // 1. Fast Path: size and mtime match + if (stats.size === expected.size && stats.mtimeMs === expected.mtimeMs) { + return true; + } + + // 2. Slow Path: content hash fallback + const currentContent = await readFile(filePath); + const currentHash = calculateHash(currentContent); + if (currentHash === expected.hash) { + // Heal cache entry with new metadata + watchFilesMetadata[filePath] = { + ...expected, + mtimeMs: stats.mtimeMs, + size: stats.size, + }; + healed = true; + + return true; + } + + return false; + } catch { + return false; + } + }); + + if (isValidResults.some((isValid) => !isValid)) { + return false; + } + + if (healed) { + try { + await store.put(cacheKey, cached); + } catch { + // Ignore errors writing healed entries + } + } + + return true; +} + +/** + * Computes metadata (content hashes, mtime, size) for an array of watch file paths. + * Processes files with a sliding worker pool of 16 concurrent operations. + */ +async function computeMetadataForWatchFiles( + watchFiles: string[], + knownContents?: Map, +): Promise> { + const watchFilesMetadata: Record = {}; + + await runConcurrent(watchFiles, MAX_CONCURRENT_READS, async (filePath) => { + try { + const knownContent = knownContents?.get(filePath); + const [content, stats] = await Promise.all([ + knownContent !== undefined ? knownContent : readFile(filePath), + stat(filePath), + ]); + const hash = calculateHash(content); + watchFilesMetadata[filePath] = { + hash, + mtimeMs: stats.mtimeMs, + size: stats.size, + }; + } catch { + // Ignore unreadable files + } + }); + + return watchFilesMetadata; +} + +export class PersistentLoadResultCache implements LoadResultCache { + private readonly memoryCache = new MemoryLoadResultCache(); + + constructor( + private readonly persistentStore?: PersistentCacheStore, + private readonly globalConfigHash: string = '', + ) {} + + /** + * Retrieves a load result from cache. + * Checks L1 memory cache first for immediate watch-mode speed, falling back to L2 persistent disk + * store on L1 cache miss. L2 persistent cache entries are validated against dependency metadata. + */ + async get(path: string): Promise { + // 1. Check L1 Memory Cache + const memoryResult = this.memoryCache.get(path); + if (memoryResult) { + return memoryResult; + } + + if (!this.persistentStore) { + return undefined; + } + + // 2. Check L2 Persistent Disk Cache + const cacheKey = calculateCacheKey(this.globalConfigHash, path); + const cached = await this.persistentStore.get(cacheKey); + + if ( + cached && + (await validateAndHealCacheEntry( + cached.watchFilesMetadata, + this.persistentStore, + cacheKey, + cached, + )) + ) { + const result: OnLoadResult = { + contents: cached.contents, + loader: cached.loader, + watchFiles: cached.watchFiles, + watchDirs: cached.watchDirs, + warnings: cached.warnings, + errors: cached.errors, + }; + + // Populate L1 Memory Cache for subsequent lookups + await this.memoryCache.put(path, result); + + return result; + } + + return undefined; + } + + /** + * Stores a load result in both L1 memory cache and L2 persistent disk store. + */ + async put(path: string, result: OnLoadResult): Promise { + await this.memoryCache.put(path, result); + + // Persist to L2 store if persistentStore is configured and contents exist (including empty strings/buffers) + if (this.persistentStore && result.contents !== undefined) { + let content: string | Uint8Array = ''; + const filePath = extractDiskFilePath(path); + if (filePath) { + try { + content = await readFile(filePath); + } catch { + // Skip L2 persistent store if target disk file cannot be read + return; + } + } + + const cacheKey = calculateCacheKey(this.globalConfigHash, path); + + // Reuse the target file's pre-read content buffer to avoid redundant disk reads (readFile) + // during dependency watch file metadata computation. + const knownContents = filePath + ? new Map([[filePath, content]]) + : undefined; + const allWatchFiles = Array.from( + new Set(filePath ? [filePath, ...(result.watchFiles ?? [])] : result.watchFiles), + ); + const watchFilesMetadata = await computeMetadataForWatchFiles(allWatchFiles, knownContents); + + await this.persistentStore.put(cacheKey, { + contents: result.contents, + loader: result.loader, + watchFiles: result.watchFiles ?? [], + watchDirs: result.watchDirs, + watchFilesMetadata, + warnings: result.warnings, + errors: result.errors, + }); + } + } + + /** + * Invalidates cached entries affected by a modified dependency file during watch mode. + * + * Note: Invalidation of L1 memory cache is sufficient for active watch mode. + * Cross-process/cold start stale entries in L2 persistent store are automatically handled + * during `get()` via dependency metadata verification (`validateAndHealCacheEntry`). + */ + invalidate(path: string): boolean { + return this.memoryCache.invalidate(path); + } + + get watchFiles(): ReadonlyArray { + return this.memoryCache.watchFiles; + } +} diff --git a/packages/angular/build/src/tools/esbuild/persistent-load-result-cache_spec.ts b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache_spec.ts new file mode 100644 index 000000000000..b41add55031d --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache_spec.ts @@ -0,0 +1,263 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { OnLoadResult } from 'esbuild'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { Cache as PersistentCacheStore } from '../../utils/cache'; +import { initializeHash } from '../../utils/hash'; +import { + type CachedLoadResultEntry, + PersistentLoadResultCache, + extractDiskFilePath, +} from './persistent-load-result-cache'; + +describe('extractDiskFilePath', () => { + it('should extract disk file path for file: URIs', () => { + const filePath = '/Users/test/project/file.js'; + expect(extractDiskFilePath(`file://${filePath}`)).toBe(filePath); + }); + + it('should extract disk file path for custom plugin namespaces', () => { + const filePath = '/Users/test/project/file.js'; + expect(extractDiskFilePath(`sass:${filePath}`)).toBe(filePath); + }); + + it('should not strip Windows drive letter as a namespace prefix', () => { + const winPath = 'C:/Users/test/project/file.js'; + expect(extractDiskFilePath(winPath)).not.toBe('/Users/test/project/file.js'); + }); +}); + +describe('PersistentLoadResultCache', () => { + let mockStore: Map; + let persistentStore: PersistentCacheStore; + let tmpDir: string; + let file1: string; + + beforeAll(async () => { + await initializeHash(); + }); + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'persistent-cache-test-')); + file1 = path.join(tmpDir, 'test.js'); + fs.writeFileSync(file1, 'console.log("hello");'); + + mockStore = new Map(); + persistentStore = { + async get(key: string) { + return mockStore.get(key); + }, + async put(key: string, value: CachedLoadResultEntry) { + mockStore.set(key, value); + }, + } as unknown as PersistentCacheStore; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return undefined on L1 and L2 cache miss', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const result = await cache.get(file1); + expect(result).toBeUndefined(); + }); + + it('should hit L2 persistent store and return cached output when dependencies are valid', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const sampleResult: OnLoadResult = { + contents: 'console.log("hello");', + loader: 'js', + watchFiles: [file1], + }; + + await cache.put(file1, sampleResult); + + // Create a second cache instance (simulating cold start) + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(file1); + + expect(hit).toBeDefined(); + expect(hit?.contents).toBe('console.log("hello");'); + expect(hit?.loader).toBe('js'); + }); + + it('should hit L2 persistent store and return cached output for empty file contents', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const sampleResult: OnLoadResult = { + contents: '', + loader: 'css', + watchFiles: [file1], + }; + + await cache.put(file1, sampleResult); + + // Create a second cache instance (simulating cold start) + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(file1); + + expect(hit).toBeDefined(); + expect(hit?.contents).toBe(''); + expect(hit?.loader).toBe('css'); + }); + + it('should preserve watchDirs when hitting L2 persistent store', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const sampleResult: OnLoadResult = { + contents: 'console.log("hello");', + loader: 'js', + watchFiles: [file1], + watchDirs: [tmpDir], + }; + + await cache.put(file1, sampleResult); + + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(file1); + + expect(hit).toBeDefined(); + expect(hit?.watchDirs).toEqual([tmpDir]); + }); + + it('should hit L2 persistent store for custom plugin namespaces backed by disk files', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const customPath = `sass:${file1}`; + const sampleResult: OnLoadResult = { + contents: '.btn { color: red; }', + loader: 'css', + watchFiles: [file1], + }; + + await cache.put(customPath, sampleResult); + + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(customPath); + + expect(hit).toBeDefined(); + expect(hit?.contents).toBe('.btn { color: red; }'); + expect(hit?.loader).toBe('css'); + }); + + it('should hit L2 persistent store for virtual modules without disk representation', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const virtualPath = 'angular:styles/component:css;0;data'; + const sampleResult: OnLoadResult = { + contents: 'h1 { margin: 0; }', + loader: 'css', + watchFiles: [file1], + }; + + await cache.put(virtualPath, sampleResult); + + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(virtualPath); + + expect(hit).toBeDefined(); + expect(hit?.contents).toBe('h1 { margin: 0; }'); + expect(hit?.loader).toBe('css'); + }); + + it('should invalidate L2 persistent cache hit if a watch dependency file is modified', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const sampleResult: OnLoadResult = { + contents: 'console.log("hello");', + loader: 'js', + watchFiles: [file1], + }; + + await cache.put(file1, sampleResult); + + // Modify dependency file content + fs.writeFileSync(file1, 'console.log("world");'); + + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(file1); + + expect(hit).toBeUndefined(); + }); + + it('should heal the cache entry with new metadata if content hash is still valid after mtime changes', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const sampleResult: OnLoadResult = { + contents: 'console.log("hello");', + loader: 'js', + watchFiles: [file1], + }; + + await cache.put(file1, sampleResult); + + // Retrieve the cache key from store + const keys = Array.from(mockStore.keys()); + expect(keys.length).toBe(1); + const cacheKey = keys[0]; + + const initialEntry = mockStore.get(cacheKey); + const initialMtime = initialEntry?.watchFilesMetadata[file1].mtimeMs; + expect(initialMtime).toBeDefined(); + + // Artificially change file modification time without changing content + const futureTime = new Date(Date.now() + 50000); + fs.utimesSync(file1, futureTime, futureTime); + + const statsAfter = fs.statSync(file1); + expect(statsAfter.mtimeMs).not.toEqual(initialMtime as number); + + // Query cold cache to trigger fallback content hash check and healing + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(file1); + + expect(hit).toBeDefined(); + expect(hit?.contents).toBe('console.log("hello");'); + + // Verify metadata was healed in-place in the persistent store + const healedEntry = mockStore.get(cacheKey); + expect(healedEntry?.watchFilesMetadata[file1].mtimeMs).toEqual(statsAfter.mtimeMs); + }); + + it('should safely handle corrupted cache entries with missing watchFilesMetadata', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const sampleResult: OnLoadResult = { + contents: 'console.log("hello");', + loader: 'js', + watchFiles: [file1], + }; + + await cache.put(file1, sampleResult); + + // Corrupt entry in store by removing watchFilesMetadata + const keys = Array.from(mockStore.keys()); + expect(keys.length).toBe(1); + const entry = mockStore.get(keys[0]); + expect(entry).toBeDefined(); + if (entry) { + delete (entry as Partial).watchFilesMetadata; + } + + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(file1); + + expect(hit).toBeUndefined(); + }); + + it('should skip L2 store put if target file cannot be read from disk', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const nonExistentFile = path.join(tmpDir, 'non-existent.js'); + const sampleResult: OnLoadResult = { + contents: 'console.log("missing");', + loader: 'js', + watchFiles: [], + }; + + await cache.put(nonExistentFile, sampleResult); + + expect(mockStore.size).toBe(0); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/platform-server-domino-plugin.ts b/packages/angular/build/src/tools/esbuild/platform-server-domino-plugin.ts new file mode 100644 index 000000000000..198d44f0c6c4 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/platform-server-domino-plugin.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Plugin } from 'esbuild'; +import { toPosixPath } from '../../utils/path'; + +/** + * Filter matching the Domino bundle import inside `@angular/platform-server`. + */ +const BUNDLED_DOMINO_FILTER = /[\\/]third_party[\\/]domino[\\/]bundled-domino\.mjs$/; + +/** + * The property name on `globalThis` where the Domino instance is stored. + */ +const DOMINO_GLOBAL_PROPERTY = 'ɵdomino'; + +/** + * Creates an esbuild plugin that attaches the Domino instance to `globalThis` when + * `@angular/platform-server/init` is bundled into the server polyfills bundle. + * + * @returns An esbuild plugin. + */ +export function createPlatformServerDominoExposePlugin(): Plugin { + return { + name: 'angular-platform-server-domino-expose', + setup(build) { + build.onResolve({ filter: BUNDLED_DOMINO_FILTER }, (args) => + args.namespace !== 'angular:domino-expose' && args.kind === 'import-statement' + ? { + path: 'angular:platform-server-domino', + namespace: 'angular:domino-expose', + pluginData: { + importPath: args.path, + resolveDir: args.resolveDir, + }, + } + : null, + ); + + build.onLoad({ filter: /./, namespace: 'angular:domino-expose' }, (args) => { + const { importPath, resolveDir } = args.pluginData as { + importPath: string; + resolveDir: string; + }; + + return { + contents: [ + `import domino from ${JSON.stringify(toPosixPath(importPath))};`, + `globalThis['${DOMINO_GLOBAL_PROPERTY}'] = domino;`, + `export default domino;`, + ].join('\n'), + loader: 'js', + resolveDir, + }; + }); + }, + }; +} + +/** + * Creates an esbuild plugin that redirects imports of Domino in `@angular/platform-server` + * to the single copy attached to `globalThis` by the server polyfills bundle. + * + * @returns An esbuild plugin. + */ +export function createPlatformServerDominoRedirectPlugin(): Plugin { + return { + name: 'angular-platform-server-domino-redirect', + setup(build) { + build.onResolve({ filter: BUNDLED_DOMINO_FILTER }, (args) => + args.kind === 'import-statement' + ? { + path: 'angular:platform-server-domino', + namespace: 'angular:platform-server-domino', + } + : null, + ); + + build.onLoad({ filter: /./, namespace: 'angular:platform-server-domino' }, () => ({ + contents: `export default globalThis['${DOMINO_GLOBAL_PROPERTY}'];`, + loader: 'js', + })); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/rxjs-esm-resolution-plugin.ts b/packages/angular/build/src/tools/esbuild/rxjs-esm-resolution-plugin.ts new file mode 100644 index 000000000000..9f813d4e9a70 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/rxjs-esm-resolution-plugin.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Plugin } from 'esbuild'; + +const RXJS_ESM_RESOLUTION = Symbol('RXJS_ESM_RESOLUTION'); + +/** + * Creates a plugin that forces ESM resolution of rxjs. + * This is needed as when targeting node, the CJS version is used to the current package conditional exports. + * @see: https://github.com/ReactiveX/rxjs/blob/2947583bb33e97f3db9e6d9f6cea70c62a173060/package.json#L19. + * + * NOTE: This can be removed when and if rxjs adds an import condition that allows ESM usage on Node.js. + * + * @returns An esbuild plugin. + */ +export function createRxjsEsmResolutionPlugin(): Plugin { + return { + name: 'angular-rxjs-resolution', + setup(build) { + build.onResolve({ filter: /^rxjs/ }, async (args) => { + if (args.pluginData?.[RXJS_ESM_RESOLUTION]) { + return null; + } + + const { importer, kind, resolveDir, namespace, pluginData = {} } = args; + pluginData[RXJS_ESM_RESOLUTION] = true; + + const result = await build.resolve(args.path, { + importer, + kind, + namespace, + pluginData, + resolveDir, + }); + + result.path = result.path.replace(/([\\/]dist[\\/])cjs([\\/])/, '$1esm$2'); + + return result; + }); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/server-bundle-metadata-plugin.ts b/packages/angular/build/src/tools/esbuild/server-bundle-metadata-plugin.ts new file mode 100644 index 000000000000..8d26782017c5 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/server-bundle-metadata-plugin.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Plugin } from 'esbuild'; + +/** + * Generates an esbuild plugin that appends metadata to the output bundle, + * marking it with server-side rendering (SSR) details for Angular SSR scenarios. + * + * @param options Optional configuration object. + * - `ssrEntryBundle`: If `true`, marks the bundle as an SSR entry point. + * + * @remarks We can't rely on `platform: node` or `platform: neutral`, as the latter + * is used for non-SSR-related code too (e.g., global scripts). + * @returns An esbuild plugin that injects SSR metadata into the build result's metafile. + */ +export function createServerBundleMetadata(options?: { ssrEntryBundle?: boolean }): Plugin { + return { + name: 'angular-server-bundle-metadata', + setup(build) { + build.onEnd((result) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const metafile = result.metafile as any; + if (metafile) { + metafile['ng-ssr-entry-bundle'] = !!options?.ssrEntryBundle; + metafile['ng-platform-server'] = true; + } + }); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/sourcemap-ignorelist-plugin.ts b/packages/angular/build/src/tools/esbuild/sourcemap-ignorelist-plugin.ts new file mode 100644 index 000000000000..7bfc4c4a9f6a --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/sourcemap-ignorelist-plugin.ts @@ -0,0 +1,214 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Plugin } from 'esbuild'; + +/** + * The field identifier for the sourcemap Chrome Devtools ignore list extension. + * + * Following the naming conventions from https://sourcemaps.info/spec.html#h.ghqpj1ytqjbm + */ +const IGNORE_LIST_ID = 'x_google_ignoreList'; + +/** + * The UTF-8 bytes for the node modules check text used to avoid unnecessary parsing + * of a full source map if not present in the source map data. + */ +const NODE_MODULE_BYTES = Buffer.from('node_modules/', 'utf-8'); + +/** + * The UTF-8 bytes for the "sources" property key used to locate the sources array. + */ +const SOURCES_KEY_BYTES = Buffer.from('"sources"', 'utf-8'); + +/** + * The UTF-8 bytes for the ignore list identifier to check if already present. + */ +const IGNORE_LIST_BYTES = Buffer.from(`"${IGNORE_LIST_ID}"`, 'utf-8'); + +/** + * Minimal sourcemap object required to create the ignore list. + */ +interface SourceMap { + sources: string[]; + [IGNORE_LIST_ID]?: number[]; +} + +function extractSources(contents: Buffer): string[] | undefined { + const sourcesKeyIndex = contents.indexOf(SOURCES_KEY_BYTES); + if (sourcesKeyIndex === -1) { + return undefined; + } + + // Find the ':' after "sources" + let colonIndex = sourcesKeyIndex + SOURCES_KEY_BYTES.length; + while (colonIndex < contents.length && contents[colonIndex] <= 0x20) { + colonIndex++; + } + if (contents[colonIndex] !== 0x3a /* : */) { + return undefined; + } + + // Find the '[' for the array + let arrayStartIndex = colonIndex + 1; + while (arrayStartIndex < contents.length && contents[arrayStartIndex] <= 0x20) { + arrayStartIndex++; + } + if (contents[arrayStartIndex] !== 0x5b /* [ */) { + return undefined; + } + + // Scan until matching ']' + let depth = 0; + let inString = false; + for (let i = arrayStartIndex; i < contents.length; i++) { + const byte = contents[i]; + if (inString) { + if (byte === 0x5c /* \ */) { + i++; // skip escaped character + } else if (byte === 0x22 /* " */) { + inString = false; + } + } else if (byte === 0x22 /* " */) { + inString = true; + } else if (byte === 0x5b /* [ */) { + depth++; + } else if (byte === 0x5d /* ] */) { + depth--; + if (depth === 0) { + try { + const slice = contents.toString('utf-8', arrayStartIndex, i + 1); + const parsed = JSON.parse(slice); + + return Array.isArray(parsed) && parsed.every((s) => typeof s === 'string') + ? (parsed as string[]) + : undefined; + } catch { + return undefined; + } + } + } + } + + return undefined; +} + +function updateSourcemapFast(contents: Buffer, ignoreList: readonly number[]): Buffer | undefined { + let braceIndex = 0; + while (braceIndex < contents.length && contents[braceIndex] <= 0x20) { + braceIndex++; + } + if (contents[braceIndex] !== 0x7b /* { */) { + return undefined; + } + + const injection = Buffer.from(`"${IGNORE_LIST_ID}":${JSON.stringify(ignoreList)},`, 'utf-8'); + + return Buffer.concat([ + contents.subarray(0, braceIndex + 1), + injection, + contents.subarray(braceIndex + 1), + ]); +} + +/** + * Creates an esbuild plugin that updates generated sourcemaps to include the Chrome + * DevTools ignore list extension. All source files that originate from a node modules + * directory are added to the ignore list by this plugin. + * + * For more information, see https://developer.chrome.com/articles/x-google-ignore-list/ + * @returns An esbuild plugin. + */ +export function createSourcemapIgnorelistPlugin(): Plugin { + return { + name: 'angular-sourcemap-ignorelist', + setup(build): void { + if (!build.initialOptions.sourcemap) { + return; + } + + build.onEnd((result) => { + if (!result.outputFiles) { + return; + } + + for (const file of result.outputFiles) { + // Only process sourcemap files + if (!file.path.endsWith('.map')) { + continue; + } + + // Create a Buffer object that shares the memory of the output file contents + const contents = Buffer.from( + file.contents.buffer, + file.contents.byteOffset, + file.contents.byteLength, + ); + + // Avoid parsing sourcemaps that have no node modules references + if (!contents.includes(NODE_MODULE_BYTES)) { + continue; + } + + let fastPathSuccess = false; + if (!contents.includes(IGNORE_LIST_BYTES)) { + const sources = extractSources(contents); + if (sources) { + const ignoreList: number[] = []; + for (let index = 0; index < sources.length; ++index) { + const location = sources[index].indexOf('node_modules/'); + if (location === 0 || (location > 0 && sources[index][location - 1] === '/')) { + ignoreList.push(index); + } + } + + if (ignoreList.length === 0) { + continue; + } + + const updated = updateSourcemapFast(contents, ignoreList); + if (updated) { + file.contents = updated; + fastPathSuccess = true; + } + } + } + + if (fastPathSuccess) { + continue; + } + + // Fallback to full JSON parse/stringify if fast scanning or splicing fails + const map = JSON.parse(contents.toString('utf-8')) as SourceMap; + if (map[IGNORE_LIST_ID]) { + continue; + } + + const ignoreList = []; + + // Check and store the index of each source originating from a node modules directory + for (let index = 0; index < map.sources.length; ++index) { + const location = map.sources[index].indexOf('node_modules/'); + if (location === 0 || (location > 0 && map.sources[index][location - 1] === '/')) { + ignoreList.push(index); + } + } + + // Avoid regenerating the source map if nothing changed + if (ignoreList.length === 0) { + continue; + } + + // Update the sourcemap in the output file + map[IGNORE_LIST_ID] = ignoreList; + file.contents = Buffer.from(JSON.stringify(map), 'utf-8'); + } + }); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/bundle-options.ts b/packages/angular/build/src/tools/esbuild/stylesheets/bundle-options.ts new file mode 100644 index 000000000000..bb5c945bab7d --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/bundle-options.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { BuildOptions, Plugin } from 'esbuild'; +import path from 'node:path'; +import { NormalizedCachedOptions } from '../../../utils/normalize-cache'; +import { PostcssConfiguration } from '../../../utils/postcss-configuration'; +import { LoadResultCache } from '../load-result-cache'; +import { createCssInlineFontsPlugin } from './css-inline-fonts-plugin'; +import { CssStylesheetLanguage } from './css-language'; +import { createCssResourcePlugin } from './css-resource-plugin'; +import { LessStylesheetLanguage } from './less-language'; +import { SassStylesheetLanguage } from './sass-language'; +import { StylesheetPluginFactory, StylesheetPluginsass } from './stylesheet-plugin-factory'; + +export interface BundleStylesheetOptions { + workspaceRoot: string; + optimization: boolean; + inlineFonts: boolean; + dataurl?: boolean; + preserveSymlinks?: boolean; + sourcemap: boolean | 'external' | 'inline' | 'linked'; + sourcesContent?: boolean; + outputNames: { bundles: string; media: string }; + includePaths?: string[]; + sass?: StylesheetPluginsass; + externalDependencies?: string[]; + target: string[]; + tailwindConfiguration?: { file: string; package: string }; + postcssConfiguration?: { config: PostcssConfiguration; configPath: string }; + publicPath?: string; + cacheOptions: NormalizedCachedOptions; +} + +export function createStylesheetBundleOptions( + options: BundleStylesheetOptions, + cache?: LoadResultCache, + inlineComponentData?: Record, +): BuildOptions & { plugins: NonNullable } { + // Ensure preprocessor include paths are absolute based on the workspace root + const includePaths = options.includePaths?.map((includePath) => + path.resolve(options.workspaceRoot, includePath), + ); + + const pluginFactory = new StylesheetPluginFactory( + { + sourcemap: !!options.sourcemap, + includePaths, + inlineComponentData, + tailwindConfiguration: options.tailwindConfiguration, + postcssConfiguration: options.postcssConfiguration, + sass: options.sass, + }, + cache, + ); + + const plugins: Plugin[] = [ + pluginFactory.create(SassStylesheetLanguage), + pluginFactory.create(LessStylesheetLanguage), + pluginFactory.create(CssStylesheetLanguage), + createCssResourcePlugin(cache, options.dataurl), + ]; + + if (options.inlineFonts) { + plugins.unshift(createCssInlineFontsPlugin({ cache, cacheOptions: options.cacheOptions })); + } + + return { + absWorkingDir: options.workspaceRoot, + bundle: true, + entryNames: options.outputNames.bundles, + assetNames: options.outputNames.media, + logLevel: 'silent', + minify: options.optimization, + metafile: true, + sourcemap: options.sourcemap, + sourcesContent: options.sourcesContent, + outdir: options.workspaceRoot, + write: false, + platform: 'browser', + target: options.target, + preserveSymlinks: options.preserveSymlinks, + external: options.externalDependencies, + publicPath: options.publicPath, + conditions: ['style', 'sass', 'less', options.optimization ? 'production' : 'development'], + mainFields: ['style', 'sass'], + // Unlike JS, CSS does not have implicit file extensions in the general case. + // Preprocessor specific behavior is handled in each stylesheet language plugin. + resolveExtensions: [], + plugins, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/css-inline-fonts-plugin.ts b/packages/angular/build/src/tools/esbuild/stylesheets/css-inline-fonts-plugin.ts new file mode 100644 index 000000000000..cbdba93c4940 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/css-inline-fonts-plugin.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Plugin, PluginBuild } from 'esbuild'; +import { InlineFontsProcessor } from '../../../utils/index-file/inline-fonts'; +import { NormalizedCachedOptions } from '../../../utils/normalize-cache'; +import { LoadResultCache, createCachedLoad } from '../load-result-cache'; + +/** + * Options for the createCssInlineFontsPlugin + * @see createCssInlineFontsPlugin + */ +export interface CssInlineFontsPluginOptions { + /** Disk cache normalized options */ + cacheOptions?: NormalizedCachedOptions; + + /** Load results cache. */ + cache?: LoadResultCache; +} + +/** + * Creates an esbuild {@link Plugin} that inlines fonts imported via import-rule. + * within the build configuration. + */ +export function createCssInlineFontsPlugin({ + cache, + cacheOptions, +}: CssInlineFontsPluginOptions): Plugin { + return { + name: 'angular-css-inline-fonts-plugin', + setup(build: PluginBuild): void { + const inlineFontsProcessor = new InlineFontsProcessor({ cache: cacheOptions, minify: false }); + + build.onResolve({ filter: /fonts\.googleapis\.com|use\.typekit\.net/ }, (args) => { + // Only attempt to resolve import-rule tokens which only exist inside CSS. + if (args.kind !== 'import-rule') { + return null; + } + + if (!inlineFontsProcessor.canInlineRequest(args.path)) { + return null; + } + + return { + path: args.path, + namespace: 'css-inline-fonts', + }; + }); + + build.onLoad( + { filter: /./, namespace: 'css-inline-fonts' }, + createCachedLoad(cache, async (args) => { + try { + return { + contents: await inlineFontsProcessor.processURL(args.path), + loader: 'css', + }; + } catch (error) { + return { + loader: 'css', + errors: [ + { + text: `Failed to inline external stylesheet '${args.path}'.`, + notes: error instanceof Error ? [{ text: error.toString() }] : undefined, + }, + ], + }; + } + }), + ); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/css-language.ts b/packages/angular/build/src/tools/esbuild/stylesheets/css-language.ts new file mode 100644 index 000000000000..f631bab2247a --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/css-language.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { StylesheetLanguage } from './stylesheet-plugin-factory'; + +export const CssStylesheetLanguage = Object.freeze({ + name: 'css', + componentFilter: /^css;/, + fileFilter: /\.css$/, +}); diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/css-resource-plugin.ts b/packages/angular/build/src/tools/esbuild/stylesheets/css-resource-plugin.ts new file mode 100644 index 000000000000..ced6422c3fd3 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/css-resource-plugin.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Plugin, PluginBuild } from 'esbuild'; +import { readFile } from 'node:fs/promises'; +import { extname, join, relative } from 'node:path'; +import { LoadResultCache, createCachedLoad } from '../load-result-cache'; + +const CSS_RESOURCE_NAMESPACE = 'angular:css-resource'; + +/** + * Symbol marker used to indicate CSS resource resolution is being attempted. + * This is used to prevent an infinite loop within the plugin's resolve hook. + */ +const CSS_RESOURCE_RESOLUTION = Symbol('CSS_RESOURCE_RESOLUTION'); + +/** + * Creates an esbuild {@link Plugin} that loads all CSS url token references using the + * built-in esbuild `file` loader. A plugin is used to allow for all file extensions + * and types to be supported without needing to manually specify all extensions + * within the build configuration. + * + * @param cache An optional load result cache. + * @param dataurl If true, resources will be loaded with the 'dataurl' loader to inline them as base64 data URIs. + * @returns An esbuild {@link Plugin} instance. + */ +export function createCssResourcePlugin(cache?: LoadResultCache, dataurl?: boolean): Plugin { + return { + name: 'angular-css-resource', + setup(build: PluginBuild): void { + build.onResolve({ filter: /.*/ }, async (args) => { + const { importer, path, kind, resolveDir, namespace, pluginData = {} } = args; + + // Only attempt to resolve url tokens which only exist inside CSS. + // Also, skip this plugin if already attempting to resolve the url-token. + if (kind !== 'url-token' || pluginData[CSS_RESOURCE_RESOLUTION]) { + return null; + } + + let [containingDir, resourceUrl] = path.split('||file:', 2); + if (resourceUrl === undefined) { + // This can happen due to early exit checks in rebasing-importer + // logic such as when the url is an external URL. + resourceUrl = containingDir; + containingDir = ''; + } + + // If root-relative, absolute or protocol relative url, mark as external to leave the + // path/URL in place. + if (/^((?:\w+:)?\/\/|data:|chrome:|#|\/)/.test(resourceUrl)) { + return { + path: resourceUrl, + external: true, + }; + } + + pluginData[CSS_RESOURCE_RESOLUTION] = true; + + const result = await build.resolve(resourceUrl, { + importer, + kind, + namespace, + pluginData, + resolveDir: join(resolveDir, containingDir), + }); + + if (result.errors.length) { + const error = result.errors[0]; + if (resourceUrl[0] === '~') { + error.notes = [ + { + location: null, + text: 'You can remove the tilde and use a relative path to reference it, which should remove this error.', + }, + ]; + } else if (resourceUrl[0] === '^') { + error.notes = [ + { + location: null, + text: + 'You can remove the caret and add the path to the `externalDependencies` build option,' + + ' which should remove this error.', + }, + ]; + } + + const extension = importer && extname(importer); + if (extension !== '.css') { + error.notes.push({ + location: null, + text: 'Preprocessor stylesheets may not show the exact file location of the error.', + }); + } + } + + // Return results that are not files since these are most likely specific to another plugin + // and cannot be loaded by this plugin. + if (result.namespace !== 'file') { + return result; + } + + // All file results are considered CSS resources and will be loaded via the file loader + return { + ...result, + // Use a relative path to prevent fully resolved paths in the metafile (JSON stats file). + // This is only necessary for custom namespaces. esbuild will handle the file namespace. + path: relative(build.initialOptions.absWorkingDir ?? '', result.path), + namespace: CSS_RESOURCE_NAMESPACE, + }; + }); + + build.onLoad( + { filter: /./, namespace: CSS_RESOURCE_NAMESPACE }, + createCachedLoad(cache, async (args) => { + const resourcePath = join(build.initialOptions.absWorkingDir ?? '', args.path); + + return { + contents: await readFile(resourcePath), + loader: dataurl ? 'dataurl' : 'file', + watchFiles: [resourcePath], + }; + }), + ); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/less-language.ts b/packages/angular/build/src/tools/esbuild/stylesheets/less-language.ts new file mode 100644 index 000000000000..68cacd576b10 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/less-language.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Location, OnLoadResult, PluginBuild } from 'esbuild'; +import { readFile } from 'node:fs/promises'; +import { StylesheetLanguage, StylesheetPluginOptions } from './stylesheet-plugin-factory'; + +/** + * The lazy-loaded instance of the less stylesheet preprocessor. + * It is only imported and initialized if a less stylesheet is used. + */ +let lessPreprocessor: typeof import('less') | undefined; + +interface LessException extends Error { + filename: string; + line: number; + column: number; + extract?: string[]; +} + +function isLessException(error: unknown): error is LessException { + return !!error && typeof error === 'object' && 'column' in error; +} + +export const LessStylesheetLanguage = Object.freeze({ + name: 'less', + componentFilter: /^less;/, + fileFilter: /\.less$/, + process(data, file, _, options, build) { + return compileString(data, file, options, build.resolve.bind(build)); + }, +}); + +async function compileString( + data: string, + filename: string, + options: StylesheetPluginOptions, + resolver: PluginBuild['resolve'], +): Promise { + try { + lessPreprocessor ??= (await import('less')).default; + } catch { + return { + errors: [ + { + text: 'Unable to load the "less" stylesheet preprocessor.', + location: null, + notes: [ + { + text: + 'Ensure that the "less" Node.js package is installed within the project. ' + + "If not present, installation via the project's package manager should resolve the error.", + }, + ], + }, + ], + }; + } + const less = lessPreprocessor; + + const resolverPlugin: Less.Plugin = { + install({ FileManager }, pluginManager): void { + const resolverFileManager = new (class extends FileManager { + override supportsSync(): boolean { + return false; + } + + override supports(): boolean { + return true; + } + + override async loadFile( + filename: string, + currentDirectory: string, + options: Less.LoadFileOptions, + environment: Less.Environment, + ): Promise { + // Attempt direct loading as a relative path to avoid resolution overhead + try { + return await super.loadFile(filename, currentDirectory, options, environment); + } catch (error) { + // Attempt a full resolution if not found + const fullResult = await resolver(filename, { + kind: 'import-rule', + resolveDir: currentDirectory, + }); + if (fullResult.path) { + return { + filename: fullResult.path, + contents: await readFile(fullResult.path, 'utf-8'), + }; + } + // Otherwise error by throwing the failing direct result + throw error; + } + } + })(); + + pluginManager.addFileManager(resolverFileManager); + }, + }; + + try { + const { imports, css } = await less.render(data, { + filename, + paths: options.includePaths, + plugins: [resolverPlugin], + rewriteUrls: 'all', + sourceMap: options.sourcemap + ? { + sourceMapFileInline: true, + outputSourceFiles: true, + } + : undefined, + } as Less.Options); + + return { + contents: css, + loader: 'css', + watchFiles: [filename, ...imports], + }; + } catch (error) { + if (isLessException(error)) { + const location = convertExceptionLocation(error); + + return { + errors: [ + { + text: error.message, + location, + }, + ], + loader: 'css', + watchFiles: location.file ? [filename, location.file] : [filename], + }; + } + + throw error; + } +} + +function convertExceptionLocation(exception: LessException): Partial { + return { + file: exception.filename, + line: exception.line, + column: exception.column, + // Middle element represents the line containing the exception + lineText: exception.extract && exception.extract[Math.trunc(exception.extract.length / 2)], + }; +} diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts new file mode 100644 index 000000000000..5c09413bbb9b --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language.ts @@ -0,0 +1,302 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Location, OnLoadResult, PartialMessage, PartialNote, ResolveResult } from 'esbuild'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { CompileResult, Exception, SourceSpan, Syntax } from 'sass-embedded'; +import { MemoryCache } from '../../../utils/cache'; +import type { SassCompiler } from '../../sass/sass-service'; +import { StylesheetLanguage, StylesheetPluginOptions } from './stylesheet-plugin-factory'; + +let sassService: SassCompiler | undefined; +let sassServicePromise: Promise | undefined; +let resolutionCache: MemoryCache | undefined; +let packageRootCache: MemoryCache | undefined; + +function isSassException(error: unknown): error is Exception { + return !!error && typeof error === 'object' && 'sassMessage' in error; +} + +export function resetSassWorkerPoolCaches(): void { + resolutionCache?.clear(); + packageRootCache?.clear(); + if (sassService) { + sassService.clearCache(); + } else if (sassServicePromise) { + void sassServicePromise.then((service) => service.clearCache()); + } +} + +export function shutdownSassWorkerPool(): void { + resetSassWorkerPoolCaches(); + if (sassService) { + void sassService.close(); + sassService = undefined; + } else if (sassServicePromise) { + void sassServicePromise.then(shutdownSassWorkerPool); + } + sassServicePromise = undefined; +} + +export const SassStylesheetLanguage = Object.freeze({ + name: 'sass', + componentFilter: /^s[ac]ss;/, + fileFilter: /\.s[ac]ss$/, + process(data, file, format, options, build) { + const syntax = format === 'sass' ? 'indented' : 'scss'; + const resolveUrl = async (url: string, resolveDir: string | undefined) => { + const path = url.startsWith('pkg:') ? url.slice(4) : url; + const result = await build.resolve(path, { + kind: 'import-rule', + resolveDir, + }); + + return result; + }; + + return compileString( + data, + file, + syntax, + options, + resolveUrl, + build.initialOptions.absWorkingDir, + ); + }, +}); + +export function isPackageUrl(url: string): boolean { + if (url.startsWith('pkg:')) { + return true; + } + + return ( + url.length > 0 && + !url.startsWith('.') && + !url.startsWith('/') && + !url.startsWith('\\') && + !url.includes(':') + ); +} + +function parsePackageName(url: string): { packageName: string; readonly pathSegments: string[] } { + const parts = (url.startsWith('pkg:') ? url.slice(4) : url).split('/'); + const hasScope = parts.length >= 2 && parts[0][0] === '@'; + const [nameOrScope, nameOrFirstPath, ...pathPart] = parts; + const packageName = hasScope ? `${nameOrScope}/${nameOrFirstPath}` : nameOrScope; + + return { + packageName, + get pathSegments() { + return !hasScope && nameOrFirstPath ? [nameOrFirstPath, ...pathPart] : pathPart; + }, + }; +} + +/** + * Returns the scope that qualifies the cached package resolutions of a stylesheet. A stylesheet + * within `node_modules` uses the root of its enclosing package, since every file of a package + * resolves its dependencies against the same `node_modules` directories. All other stylesheets use + * the working directory, allowing component stylesheets to share package resolutions. + */ +export function getPackageScope( + containingPath: string | undefined, + workingDirectory: string | undefined, +): string { + // The directory segments of the stylesheet, excluding the file name + const segments = containingPath?.split(/[\\/]/).slice(0, -1) ?? []; + const index = segments.lastIndexOf('node_modules'); + if (index === -1) { + return workingDirectory ?? ''; + } + + const packageNameLength = segments[index + 1]?.[0] === '@' ? 2 : 1; + + return segments.slice(0, index + 1 + packageNameLength).join('/'); +} + +async function compileString( + data: string, + filePath: string, + syntax: Syntax, + options: StylesheetPluginOptions, + resolveUrl: (url: string, resolveDir: string | undefined) => Promise, + workingDirectory: string | undefined, +): Promise { + // Lazily load Sass when a Sass file is found + if (sassService === undefined) { + if (sassServicePromise === undefined) { + sassServicePromise = import('../../sass/sass-service').then( + (sassService) => new sassService.SassCompiler(true), + ); + } + try { + sassService = await sassServicePromise; + } finally { + sassServicePromise = undefined; + } + } + + // Caching follows Sass behavior where a given package url will always resolve to the same value + // regardless of its importer's path, except for importers within `node_modules`, which are + // scoped to their enclosing package. Relative paths are qualified with the containing URL. + // A null value indicates that the cached resolution attempt failed to find a location and + // later stage resolution should be attempted. This avoids potentially expensive repeat + // failing resolution attempts. + resolutionCache ??= new MemoryCache(); + packageRootCache ??= new MemoryCache(); + const currentResolutionCache = resolutionCache; + const currentPackageRootCache = packageRootCache; + const warnings: PartialMessage[] = []; + const { silenceDeprecations, futureDeprecations, fatalDeprecations } = options.sass ?? {}; + + try { + const { css, sourceMap, loadedUrls } = await sassService.compileStringAsync(data, { + url: pathToFileURL(filePath), + style: 'expanded', + syntax, + loadPaths: options.includePaths, + sourceMap: options.sourcemap, + sourceMapIncludeSources: options.sourcemap, + silenceDeprecations, + fatalDeprecations, + futureDeprecations, + quietDeps: true, + importers: [ + { + findFileUrl: (url, options) => { + const containingPath = + options.containingUrl?.protocol === 'file:' + ? fileURLToPath(options.containingUrl) + : undefined; + const resolveDir = containingPath ? dirname(containingPath) : workingDirectory; + const isPackage = isPackageUrl(url); + const scope = getPackageScope(containingPath, workingDirectory); + const cacheKey = isPackage + ? `${scope}:${url}` + : `${options.containingUrl?.href ?? ''}:${url}`; + + return currentResolutionCache.getOrCreate(cacheKey, async () => { + const result = await resolveUrl(url, resolveDir); + if (result.path) { + return pathToFileURL(result.path); + } + + // Check for package deep imports + if (!isPackage) { + return null; + } + + const { packageName, pathSegments } = parsePackageName(url); + + // Caching package root locations is particularly beneficial for `@material/*` packages + // which extensively use deep imports. + const packageRoot = await currentPackageRootCache.getOrCreate( + `${scope}:${packageName}`, + async () => { + // Use the required presence of a package root `package.json` file to resolve the location + const packageResult = await resolveUrl(packageName + '/package.json', resolveDir); + + return packageResult.path ? dirname(packageResult.path) : null; + }, + ); + + // Package not found could be because of an error or the specifier is intended to be found + // via a later stage of the resolution process (`loadPaths`, etc.). + // Errors are reported after the full completion of the resolution process. Exceptions for + // not found packages should not be raised here. + if (packageRoot) { + return pathToFileURL(join(packageRoot, ...pathSegments)); + } + + // Not found + return null; + }); + }, + }, + ], + logger: { + warn: (text, { deprecation, stack, span }) => { + const notes: PartialNote[] = []; + if (deprecation) { + notes.push({ text }); + } + if (stack && !span) { + notes.push({ text: stack }); + } + + warnings.push({ + text: deprecation ? 'Deprecation' : text, + location: span && spanToLocation(span), + notes, + }); + }, + }, + }); + + return { + loader: 'css', + contents: sourceMap ? `${css}\n${sourceMapToUrlComment(sourceMap)}` : css, + watchFiles: loadedUrls.map((url) => fileURLToPath(url)), + warnings, + }; + } catch (error) { + if (isSassException(error)) { + const location = spanToLocation(error.span); + const watchFiles = [filePath, ...extractFilesFromStack(error.sassStack)]; + if (location.file) { + watchFiles.push(location.file); + } + + return { + loader: 'css', + errors: [ + { + text: error.message, + location, + }, + ], + warnings, + watchFiles, + }; + } + + throw error; + } +} + +function spanToLocation(span: SourceSpan): Partial { + return { + file: span.url && fileURLToPath(span.url), + lineText: span.context, + // Sass line numbers are 0-based while esbuild's are 1-based + line: span.start.line + 1, + column: span.start.column, + }; +} + +function sourceMapToUrlComment(sourceMap: Exclude): string { + const urlSourceMap = Buffer.from(JSON.stringify(sourceMap), 'utf-8').toString('base64'); + + return `/*# sourceMappingURL=data:application/json;charset=utf-8;base64,${urlSourceMap} */`; +} + +function* extractFilesFromStack(stack: string): Iterable { + const lines = stack.split('\n'); + const cwd = process.cwd(); + + // Stack line has format of ` : ` + for (const line of lines) { + const match = line.trim().match(/^(.+?)\s+\d+:\d+/); + if (match) { + // Stack paths from sass are relative to the current working directory (not input file or workspace root) + yield join(cwd, match[1]); + } + } +} diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/sass-language_spec.ts b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language_spec.ts new file mode 100644 index 000000000000..ec424208eadc --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/sass-language_spec.ts @@ -0,0 +1,425 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { PluginBuild } from 'esbuild'; +import assert from 'node:assert'; +import { statSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { SassCompiler } from '../../sass/sass-service'; +import { + SassStylesheetLanguage, + getPackageScope, + isPackageUrl, + resetSassWorkerPoolCaches, + shutdownSassWorkerPool, +} from './sass-language'; + +describe('sass-language', () => { + describe('isPackageUrl', () => { + it('should identify pkg: scheme URLs as package URLs', () => { + expect(isPackageUrl('pkg:@angular/material')).toBeTrue(); + expect(isPackageUrl('pkg:bootstrap')).toBeTrue(); + expect(isPackageUrl('pkg:@material/button/button')).toBeTrue(); + }); + + it('should identify bare specifiers as package URLs', () => { + expect(isPackageUrl('@angular/material')).toBeTrue(); + expect(isPackageUrl('@angular/material/button')).toBeTrue(); + expect(isPackageUrl('@material/button/button.scss')).toBeTrue(); + expect(isPackageUrl('bootstrap')).toBeTrue(); + expect(isPackageUrl('bootstrap/scss/bootstrap')).toBeTrue(); + }); + + it('should not identify relative paths as package URLs', () => { + expect(isPackageUrl('./styles.scss')).toBeFalse(); + expect(isPackageUrl('../shared/variables')).toBeFalse(); + expect(isPackageUrl('.hidden')).toBeFalse(); + expect(isPackageUrl('.\\styles.scss')).toBeFalse(); + expect(isPackageUrl('..\\shared\\variables')).toBeFalse(); + }); + + it('should not identify absolute paths or non-pkg URLs as package URLs', () => { + expect(isPackageUrl('/styles/theme.scss')).toBeFalse(); + expect(isPackageUrl('\\styles\\theme.scss')).toBeFalse(); + expect(isPackageUrl('file:///path/to/theme.scss')).toBeFalse(); + expect(isPackageUrl('http://example.com/styles.css')).toBeFalse(); + expect(isPackageUrl('https://example.com/styles.css')).toBeFalse(); + expect(isPackageUrl('C:\\path\\to\\theme.scss')).toBeFalse(); + expect(isPackageUrl('C:/path/to/theme.scss')).toBeFalse(); + }); + + it('should not identify empty string as a package URL', () => { + expect(isPackageUrl('')).toBeFalse(); + }); + }); + + describe('getPackageScope', () => { + const scope = (containingPath?: string) => getPackageScope(containingPath, '/app'); + + it('should use the working directory for a stylesheet outside node_modules', () => { + expect(scope('/app/src/app.scss')).toBe('/app'); + expect(scope('/app/src/foo-node_modules/a.scss')).toBe('/app'); + expect(scope('/app/src/node_modules.scss')).toBe('/app'); + expect(scope(undefined)).toBe('/app'); + }); + + it('should use the enclosing package root for a stylesheet within node_modules', () => { + expect(scope('/app/node_modules/pkg/a.scss')).toBe('/app/node_modules/pkg'); + expect(scope('/app/node_modules/pkg/sub/a.scss')).toBe('/app/node_modules/pkg'); + expect(scope('/app/node_modules/@scope/pkg/sub/a.scss')).toBe('/app/node_modules/@scope/pkg'); + expect(scope('/app/node_modules/foo-node_modules/a.scss')).toBe( + '/app/node_modules/foo-node_modules', + ); + expect(scope('/app/node_modules/a.scss')).toBe('/app/node_modules'); + }); + + it('should use the innermost package root for a nested dependency', () => { + expect(scope('/app/node_modules/dep/node_modules/pkg/a.scss')).toBe( + '/app/node_modules/dep/node_modules/pkg', + ); + expect(scope('/app/node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/a.scss')).toBe( + '/app/node_modules/.pnpm/pkg@1.0.0/node_modules/pkg', + ); + }); + + it('should support Windows path separators', () => { + expect(getPackageScope('C:\\app\\node_modules\\pkg\\sub\\a.scss', 'C:\\app')).toBe( + 'C:/app/node_modules/pkg', + ); + expect(getPackageScope('C:\\app\\src\\app.scss', 'C:\\app')).toBe('C:\\app'); + }); + }); + + describe('package resolution caching', () => { + let temporaryRoot: string; + let projectRoot: string; + let buttonStylesheet: string; + let cardStylesheet: string; + let dependencyStylesheet: string; + let resolveRequests: string[]; + + /** + * Creates a build stub that resolves a package specifier by searching the `node_modules` + * directories visible from the resolve directory, which is how esbuild resolves the + * package specifiers of a stylesheet. + */ + function createBuildStub(): PluginBuild { + return { + initialOptions: { absWorkingDir: projectRoot }, + resolve: async (path: string, options: { resolveDir: string }) => { + resolveRequests.push(`${options.resolveDir}:${path}`); + + for (let directory = options.resolveDir; ; directory = dirname(directory)) { + // A package specifier resolves to the index file of the package, and an explicit file + // within it to that file. A deeper subpath is left unresolved, as esbuild leaves one + // that the `exports` of the package does not name; the Sass importer then resolves it + // against the package root instead. + for (const candidate of [ + join(directory, 'node_modules', path, '_index.scss'), + join(directory, 'node_modules', path), + ]) { + if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) { + return { path: candidate, errors: [], warnings: [] }; + } + } + + if (dirname(directory) === directory) { + return { path: undefined, errors: [], warnings: [] }; + } + } + }, + } as unknown as PluginBuild; + } + + async function compile(stylesheet: string, source = "@use 'theme';"): Promise { + const result = await SassStylesheetLanguage.process?.( + source, + stylesheet, + 'scss', + { sourcemap: false }, + createBuildStub(), + ); + if (!result) { + throw new Error('The Sass stylesheet language has no process function.'); + } + + if (result.errors?.length) { + return `error: ${result.errors[0].text}`; + } + + return (result.contents as string).trim(); + } + + async function writePackage(directory: string, marker: string): Promise { + await mkdir(join(directory, 'sub'), { recursive: true }); + await writeFile(join(directory, 'package.json'), '{}'); + await writeFile(join(directory, '_index.scss'), `.marker { content: "${marker}"; }`); + await writeFile( + join(directory, 'sub', '_other.scss'), + `.deep { content: "${marker} deep"; }`, + ); + } + + beforeAll(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + temporaryRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-sass-language-')); + projectRoot = join(temporaryRoot, 'project'); + const dependencyRoot = join(projectRoot, 'node_modules', 'dependency'); + + // An application using a `theme` package, and a dependency with its own nested version of + // `theme` plus an `extra` package that only the dependency can see. + await writePackage(join(projectRoot, 'node_modules', 'theme'), 'project'); + await writePackage(join(dependencyRoot, 'node_modules', 'theme'), 'dependency'); + await writePackage(join(dependencyRoot, 'node_modules', 'extra'), 'extra'); + + buttonStylesheet = join(projectRoot, 'src', 'app', 'button', 'button.scss'); + cardStylesheet = join(projectRoot, 'src', 'app', 'card', 'card.scss'); + dependencyStylesheet = join(dependencyRoot, 'styles.scss'); + for (const stylesheet of [buttonStylesheet, cardStylesheet]) { + await mkdir(dirname(stylesheet), { recursive: true }); + } + }); + + afterAll(async () => { + shutdownSassWorkerPool(); + await rm(temporaryRoot, { force: true, recursive: true }); + }); + + beforeEach(() => { + resetSassWorkerPoolCaches(); + resolveRequests = []; + }); + + it('should not use the package resolution of a dependency for the application', async () => { + const dependency = await compile(dependencyStylesheet); + const application = await compile(buttonStylesheet); + + expect(dependency).toContain('content: "dependency";'); + expect(application).toContain('content: "project";'); + }); + + it('should not use the package resolution of the application for a dependency', async () => { + const application = await compile(buttonStylesheet); + const dependency = await compile(dependencyStylesheet); + + expect(application).toContain('content: "project";'); + expect(dependency).toContain('content: "dependency";'); + }); + + it('should not reuse a failed package resolution of the application for a dependency', async () => { + const source = "@use 'extra';"; + const application = await compile(buttonStylesheet, source); + const dependency = await compile(dependencyStylesheet, source); + + expect(application).toContain("Can't find stylesheet to import."); + expect(dependency).toContain('content: "extra";'); + }); + + it('should not use the package root of a dependency for a deep import of the application', async () => { + // A subpath that resolves to no file of its own is located through the root of the package, + // which is cached separately from the resolution of the specifier. + const source = "@use 'theme/sub/other';"; + const dependency = await compile(dependencyStylesheet, source); + const application = await compile(buttonStylesheet, source); + + expect(dependency).toContain('content: "dependency deep";'); + expect(application).toContain('content: "project deep";'); + }); + + it('should share a package resolution between the stylesheets of different components', async () => { + const button = await compile(buttonStylesheet); + const card = await compile(cardStylesheet); + + expect(button).toContain('content: "project";'); + expect(card).toContain('content: "project";'); + expect(resolveRequests.length).toBe(1); + }); + + it('should share a package resolution between the stylesheets of different folders of a dependency', async () => { + const dependencyRoot = join(projectRoot, 'node_modules', 'dependency'); + const first = await compile(join(dependencyRoot, 'sub1', 'styles.scss')); + const second = await compile(join(dependencyRoot, 'sub2', 'styles.scss')); + + expect(first).toContain('content: "dependency";'); + expect(second).toContain('content: "dependency";'); + expect(resolveRequests.length).toBe(1); + }); + + it('should share a package resolution between the stylesheets of different folders of a scoped package', async () => { + const packageRoot = join(projectRoot, 'node_modules', '@scope', 'pkg'); + const first = await compile(join(packageRoot, 'sub1', 'styles.scss')); + const second = await compile(join(packageRoot, 'sub2', 'styles.scss')); + + expect(first).toContain('content: "project";'); + expect(second).toContain('content: "project";'); + expect(resolveRequests.length).toBe(1); + }); + + it('should resolve a package url of a non-file containing URL from the working directory', async () => { + // The stylesheets of a build have file URLs, but Sass does not limit a containing URL to them. + spyOn(SassCompiler.prototype, 'compileStringAsync').and.callFake(async (_, options) => { + const importer = options.importers?.[0] as { + findFileUrl( + url: string, + context: { containingUrl: URL; fromImport: boolean }, + ): Promise; + }; + const url = await importer.findFileUrl('theme', { + containingUrl: new URL('custom:styles.scss'), + fromImport: false, + }); + + return { css: url?.href ?? '', loadedUrls: [] }; + }); + + const result = await compile(buttonStylesheet); + + expect(result).toBe( + pathToFileURL(join(projectRoot, 'node_modules', 'theme', '_index.scss')).href, + ); + }); + + it('should resolve imports when globalThis.Promise is replaced (e.g., by Zone.js)', async () => { + const originalPromise = globalThis.Promise; + + // Simulate Zone.js replacing globalThis.Promise with a constructor where + // native V8 async function return values fail `instanceof globalThis.Promise`. + class ZoneLikePromise { + #inner: Promise; + + constructor( + executor: ( + resolve: (value: T | PromiseLike) => void, + reject: (reason?: unknown) => void, + ) => void, + ) { + this.#inner = new originalPromise(executor); + } + + then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): ZoneLikePromise { + return new ZoneLikePromise((resolve, reject) => { + this.#inner.then(onfulfilled, onrejected).then(resolve, reject); + }); + } + + catch( + onrejected?: ((reason: unknown) => TResult | PromiseLike) | null, + ): ZoneLikePromise { + return this.then(undefined, onrejected); + } + + finally(onfinally?: (() => void) | null): ZoneLikePromise { + return new ZoneLikePromise((resolve, reject) => { + this.#inner.finally(onfinally).then(resolve, reject); + }); + } + + static resolve(value?: U | PromiseLike): ZoneLikePromise> { + if (value instanceof ZoneLikePromise) { + return value; + } + + return new ZoneLikePromise((resolve) => resolve(value as Awaited)); + } + + static reject(reason?: unknown): ZoneLikePromise { + return new ZoneLikePromise((_, reject) => reject(reason)); + } + + static all(values: Iterable): ZoneLikePromise { + return ZoneLikePromise.resolve(originalPromise.all(values)); + } + } + + globalThis.Promise = ZoneLikePromise as unknown as PromiseConstructor; + try { + const application = await compile(buttonStylesheet); + expect(application).toContain('content: "project";'); + } finally { + globalThis.Promise = originalPromise; + } + }); + }); + + describe('error handling', () => { + const buildStub = { + initialOptions: { absWorkingDir: '/app' }, + resolve: async () => ({ path: undefined, errors: [], warnings: [] }), + } as unknown as PluginBuild; + + afterAll(() => { + shutdownSassWorkerPool(); + }); + + it('should populate structured location on Sass compilation errors', async () => { + const result = await SassStylesheetLanguage.process?.( + 'body {\n color: $undefined-var;\n}', + '/app/src/styles.scss', + 'scss', + { sourcemap: false }, + buildStub, + ); + + expect(result?.errors?.length).toBe(1); + expect(result?.errors?.[0].text).toContain('Undefined variable.'); + expect(result?.errors?.[0].location).toEqual({ + file: '/app/src/styles.scss', + line: 2, + column: 9, + lineText: jasmine.stringContaining('color: $undefined-var;'), + }); + expect(result?.watchFiles).toContain('/app/src/styles.scss'); + }); + + it('should skip stack trace lines without line:col and extract valid stack files', async () => { + spyOn(SassCompiler.prototype, 'compileStringAsync').and.rejectWith({ + message: 'Custom Sass error', + sassMessage: 'Custom Sass error', + sassStack: 'malformed stack line without location\nsrc/partials/_vars.scss 3:5 @use\n', + span: { + url: pathToFileURL('/app/src/partials/_vars.scss'), + context: '$bad: ;', + start: { line: 2, column: 4, offset: 10 }, + end: { line: 2, column: 5, offset: 11 }, + text: ';', + }, + }); + + const result = await SassStylesheetLanguage.process?.( + '@use "partials/vars";', + '/app/src/styles.scss', + 'scss', + { sourcemap: false }, + buildStub, + ); + + expect(result?.errors).toEqual([ + { + text: 'Custom Sass error', + location: { + file: '/app/src/partials/_vars.scss', + lineText: '$bad: ;', + line: 3, + column: 4, + }, + }, + ]); + expect(result?.watchFiles).toEqual([ + '/app/src/styles.scss', + join(process.cwd(), 'src/partials/_vars.scss'), + '/app/src/partials/_vars.scss', + ]); + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts new file mode 100644 index 000000000000..d760a14197ec --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { calculateHash } from '../../../utils/hash'; +import type { BundleStylesheetOptions } from './bundle-options'; + +/** + * Generates a global hash based on all build options that affect stylesheet compilation. + * + * IMPORTANT: This hash acts as the root cache key prefix for all persistent stylesheet cache + * entries. Any change in the values hashed below will invalidate all cached stylesheet outputs + * across the entire application. + * + * Included build options and their rationale: + * - `optimization`: Affects CSS minification, dead code elimination, and whitespace stripping. + * - `sourcemap`: Toggles inline/external source map generation and comment insertion. + * - `sourcesContent`: Toggles embedding original source content inside generated source maps. + * - `includePaths`: Changes Sass/Less `@import` and `@use` path resolution search directories. + * - `sassOptions`: Changes Sass compiler deprecations and behavior flags. + * - `target`: Affects CSS property lowering and browser vendor prefixing in esbuild. + * - `publicPath`: Affects relative asset URL rewriting (`url('...')`) inside CSS output. + * - `outputNames`: Affects asset output filename hashing schemes. + * - `inlineFonts`: Controls whether external web font `@import` / `` directives are inlined. + * - `dataurl`: Controls whether referenced assets are inlined as base64 data URIs. + * - `preserveSymlinks`: Controls symlink realpath resolution in monorepos/pnpm workspace packages. + * - `externalDependencies`: Controls which CSS modules/urls are excluded from bundling. + * - `postcssConfig`: Path to custom PostCSS configuration file. + * - `tailwindConfig`: Path to Tailwind CSS configuration file. + * - `packageVersion`: Invalidates cache across `@angular/build` compiler toolchain updates. + * + * @note Maintainers: If any new build option is added to `BundleStylesheetOptions` or `@angular/build` + * that alters generated stylesheet code or asset outputs, it MUST be added to this hash object. + */ +export function calculateGlobalStylesheetConfigHash( + options: BundleStylesheetOptions, + packageVersion: string = '', +): string { + return calculateHash( + JSON.stringify({ + optimization: options.optimization, + sourcemap: options.sourcemap, + sourcesContent: options.sourcesContent, + includePaths: options.includePaths, + sassOptions: options.sass + ? { + futureDeprecations: options.sass.futureDeprecations, + fatalDeprecations: options.sass.fatalDeprecations, + silenceDeprecations: options.sass.silenceDeprecations, + } + : undefined, + target: options.target, + publicPath: options.publicPath, + outputNames: options.outputNames, + inlineFonts: options.inlineFonts, + dataurl: options.dataurl ?? false, + preserveSymlinks: options.preserveSymlinks, + externalDependencies: options.externalDependencies, + postcssConfig: options.postcssConfiguration?.configPath + ? options.postcssConfiguration.configPath + : '', + tailwindConfig: options.tailwindConfiguration?.file ? options.tailwindConfiguration.file : '', + packageVersion, + }), + ); +} diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts new file mode 100644 index 000000000000..49b232720fbf --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { initializeHash } from '../../../utils/hash'; +import type { BundleStylesheetOptions } from './bundle-options'; +import { calculateGlobalStylesheetConfigHash } from './stylesheet-cache-key'; + +describe('Stylesheet Global Config Hash', () => { + beforeAll(async () => { + await initializeHash(); + }); + + const baseOptions: BundleStylesheetOptions = { + workspaceRoot: '/root', + optimization: true, + inlineFonts: false, + sourcemap: true, + outputNames: { bundles: 'styles', media: 'media' }, + target: ['chrome100'], + cacheOptions: { enabled: true, path: '/cache', basePath: '/root' }, + }; + + describe('calculateGlobalStylesheetConfigHash', () => { + it('should generate consistent hashes for identical options', () => { + const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0'); + const hash2 = calculateGlobalStylesheetConfigHash({ ...baseOptions }, '1.0.0'); + expect(hash1).toBe(hash2); + }); + + it('should produce different hashes when optimization changes', () => { + const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0'); + const hash2 = calculateGlobalStylesheetConfigHash( + { ...baseOptions, optimization: false }, + '1.0.0', + ); + expect(hash1).not.toBe(hash2); + }); + + it('should produce different hashes when sourcemap options change', () => { + const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0'); + const hash2 = calculateGlobalStylesheetConfigHash( + { ...baseOptions, sourcemap: false }, + '1.0.0', + ); + expect(hash1).not.toBe(hash2); + }); + + it('should produce different hashes when sourcesContent changes', () => { + const hash1 = calculateGlobalStylesheetConfigHash( + { ...baseOptions, sourcesContent: true }, + '1.0.0', + ); + const hash2 = calculateGlobalStylesheetConfigHash( + { ...baseOptions, sourcesContent: false }, + '1.0.0', + ); + expect(hash1).not.toBe(hash2); + }); + + it('should produce different hashes when target browsers change', () => { + const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0'); + const hash2 = calculateGlobalStylesheetConfigHash( + { ...baseOptions, target: ['firefox90'] }, + '1.0.0', + ); + expect(hash1).not.toBe(hash2); + }); + + it('should produce different hashes when dataurl changes', () => { + const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0'); + const hash2 = calculateGlobalStylesheetConfigHash({ ...baseOptions, dataurl: true }, '1.0.0'); + expect(hash1).not.toBe(hash2); + }); + + it('should produce the same hash when dataurl is undefined or false', () => { + const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0'); + const hash2 = calculateGlobalStylesheetConfigHash( + { ...baseOptions, dataurl: false }, + '1.0.0', + ); + expect(hash1).toBe(hash2); + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts new file mode 100644 index 000000000000..79337fe1652e --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-plugin-factory.ts @@ -0,0 +1,444 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { OnLoadResult, Plugin, PluginBuild } from 'esbuild'; +import assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, extname } from 'node:path'; +import type { Options } from 'sass-embedded'; +import { glob } from 'tinyglobby'; +import { assertIsError } from '../../../utils/error'; +import type { PostcssConfiguration } from '../../../utils/postcss-configuration'; +import { LoadResultCache, createCachedLoad } from '../load-result-cache'; + +/** + * Configuration options for handling Sass-specific deprecations in a stylesheet plugin. + */ +export type StylesheetPluginsass = Pick< + Options<'async'>, + 'futureDeprecations' | 'fatalDeprecations' | 'silenceDeprecations' +>; + +/** + * Convenience type for a postcss processor. + */ +type PostcssProcessor = import('postcss').Processor; + +/** + * The lazy-loaded instance of the postcss stylesheet postprocessor. + * It is only imported and initialized if postcss is needed. + */ +let postcss: (typeof import('postcss'))['default'] | undefined; + +/** + * An object containing the plugin options to use when processing stylesheets. + */ +export interface StylesheetPluginOptions { + /** + * Controls the use and creation of sourcemaps when processing the stylesheets. + * If true, sourcemap processing is enabled; if false, disabled. + */ + sourcemap: boolean; + + /** + * An optional array of paths that will be searched for stylesheets if the default + * resolution process for the stylesheet language does not succeed. + */ + includePaths?: string[]; + + /** + * Optional component data for any inline styles from Component decorator `styles` fields. + * The key is an internal angular resource URI and the value is the stylesheet content. + */ + inlineComponentData?: Record; + + /** + * Optional information used to load and configure Tailwind CSS. If present, the postcss + * will be added to the stylesheet processing with the Tailwind plugin setup as provided + * by the configuration file. + */ + tailwindConfiguration?: { file: string; package: string }; + + /** + * Optional configuration object for custom postcss usage. If present, postcss will be + * initialized and used for every stylesheet. This overrides the tailwind integration + * and any tailwind usage must be manually configured in the custom postcss usage. + */ + postcssConfiguration?: { config: PostcssConfiguration; configPath: string }; + + /** + * Optional Options for configuring Sass behavior. + */ + sass?: StylesheetPluginsass; +} + +/** + * An array of keywords that indicate Tailwind CSS processing is required for a stylesheet. + * + * Based on https://tailwindcss.com/docs/functions-and-directives + */ +const TAILWIND_KEYWORDS = [ + '@tailwind', + '@layer', + '@apply', + '@config', + 'theme(', + 'screen(', + '@screen', // Undocumented in version 3, see: https://github.com/tailwindlabs/tailwindcss/discussions/7516. +]; + +export interface StylesheetLanguage { + name: string; + componentFilter: RegExp; + fileFilter: RegExp; + process?( + data: string, + file: string, + format: string, + options: StylesheetPluginOptions, + build: PluginBuild, + ): OnLoadResult | Promise; +} + +/** + * Cached postcss instances that can be re-used between various StylesheetPluginFactory instances. + */ +const postcssProcessors = new Map>(); + +export class StylesheetPluginFactory { + constructor( + private readonly options: StylesheetPluginOptions, + private readonly cache?: LoadResultCache, + ) {} + + create(language: Readonly): Plugin { + const { cache, options, setupPostcss } = this; + + // Return a noop plugin if no load actions are required + if (!language.process && !options.postcssConfiguration && !options.tailwindConfiguration) { + return { + name: 'angular-' + language.name, + setup() {}, + }; + } + + return { + name: 'angular-' + language.name, + async setup(build) { + // Setup postcss if needed + let postcssProcessor: PostcssProcessor | undefined; + build.onStart(async () => { + try { + postcssProcessor = await setupPostcss; + } catch { + return { + errors: [ + { + text: 'Unable to load the "postcss" stylesheet processor.', + location: null, + notes: [ + { + text: + 'Ensure that the "postcss" Node.js package is installed within the project. ' + + "If not present, installation via the project's package manager should resolve the error.", + }, + ], + }, + ], + }; + } + }); + + // Add a load callback to support inline Component styles + build.onLoad( + { filter: language.componentFilter, namespace: 'angular:styles/component' }, + createCachedLoad(cache, (args) => { + const data = options.inlineComponentData?.[args.path]; + assert( + typeof data === 'string', + `component style name should always be found [${args.path}]`, + ); + + const [format, , filename] = args.path.split(';', 3); + + return processStylesheet( + language, + data, + filename, + format, + options, + build, + postcssProcessor, + ); + }), + ); + + // Add a load callback to support files from disk + build.onLoad( + { filter: language.fileFilter, namespace: 'file' }, + createCachedLoad(cache, async (args) => { + const data = await readFile(args.path, 'utf-8'); + + return processStylesheet( + language, + data, + args.path, + extname(args.path).toLowerCase().slice(1), + options, + build, + postcssProcessor, + ); + }), + ); + }, + }; + } + + private setupPostcssPromise: Promise | undefined; + private get setupPostcss(): Promise { + return (this.setupPostcssPromise ??= this.initPostcss()); + } + + private initPostcssCallCount = 0; + + /** + * This method should not be called directly. + * Use {@link setupPostcss} instead. + */ + private async initPostcss(): Promise { + assert.equal(++this.initPostcssCallCount, 1, '`initPostcss` was called more than once.'); + + const { options } = this; + if (options.postcssConfiguration) { + const { config, configPath } = options.postcssConfiguration; + const postCssInstanceKey = JSON.stringify(config); + let postcssProcessor = postcssProcessors.get(postCssInstanceKey)?.deref(); + + if (!postcssProcessor) { + postcss ??= (await import('postcss')).default; + postcssProcessor = postcss(); + + const postCssPluginRequire = createRequire(dirname(configPath) + '/'); + for (const [pluginName, pluginOptions] of config.plugins) { + const pluginMod = postCssPluginRequire(pluginName); + const plugin = pluginMod.__esModule ? pluginMod['default'] : pluginMod; + if (typeof plugin !== 'function' || plugin.postcss !== true) { + throw new Error(`Attempted to load invalid Postcss plugin: "${pluginName}"`); + } + + postcssProcessor.use(plugin(pluginOptions)); + } + + postcssProcessors.set(postCssInstanceKey, new WeakRef(postcssProcessor)); + } + + return postcssProcessor; + } else if (options.tailwindConfiguration) { + const { package: tailwindPackage, file: config } = options.tailwindConfiguration; + const postCssInstanceKey = tailwindPackage + ':' + config; + let postcssProcessor = postcssProcessors.get(postCssInstanceKey)?.deref(); + + if (!postcssProcessor) { + postcss ??= (await import('postcss')).default; + const tailwind = await import(tailwindPackage); + postcssProcessor = postcss().use(tailwind.default({ config })); + postcssProcessors.set(postCssInstanceKey, new WeakRef(postcssProcessor)); + } + + return postcssProcessor; + } + } +} + +async function processStylesheet( + language: Readonly, + data: string, + filename: string, + format: string, + options: StylesheetPluginOptions, + build: PluginBuild, + postcssProcessor: PostcssProcessor | undefined, +) { + let result: OnLoadResult; + + // Process the input data if the language requires preprocessing + if (language.process) { + result = await language.process(data, filename, format, options, build); + } else { + result = { + contents: data, + loader: 'css', + watchFiles: [filename], + }; + } + + // Return early if there are no contents to further process or there are errors + if (!result.contents || result.errors?.length) { + return result; + } + + // Only use postcss if Tailwind processing is required or custom postcss is present. + if (postcssProcessor && (options.postcssConfiguration || hasTailwindKeywords(result.contents))) { + const postcssResult = await compileString( + typeof result.contents === 'string' + ? result.contents + : Buffer.from(result.contents).toString('utf-8'), + filename, + postcssProcessor, + options, + ); + + // Merge results + if (postcssResult.errors?.length) { + delete result.contents; + } + if (result.warnings && postcssResult.warnings) { + postcssResult.warnings.unshift(...result.warnings); + } + if (result.watchFiles && postcssResult.watchFiles) { + postcssResult.watchFiles.unshift(...result.watchFiles); + } + if (result.watchDirs && postcssResult.watchDirs) { + postcssResult.watchDirs.unshift(...result.watchDirs); + } + result = { + ...result, + ...postcssResult, + }; + } + + return result; +} + +/** + * Searches the provided contents for keywords that indicate Tailwind is used + * within a stylesheet. + * @param contents A string or Uint8Array containing UTF-8 text. + * @returns True, if the contents contains tailwind keywords; False, otherwise. + */ +function hasTailwindKeywords(contents: string | Uint8Array): boolean { + // TODO: use better search algorithm for keywords + if (typeof contents === 'string') { + return TAILWIND_KEYWORDS.some((keyword) => contents.includes(keyword)); + } + + // Contents is a Uint8Array + const data = contents instanceof Buffer ? contents : Buffer.from(contents); + + return TAILWIND_KEYWORDS.some((keyword) => data.includes(keyword)); +} + +/** + * Compiles the provided CSS stylesheet data using a provided postcss processor and provides an + * esbuild load result that can be used directly by an esbuild Plugin. + * @param data The stylesheet content to process. + * @param filename The name of the file that contains the data. + * @param postcssProcessor A postcss processor instance to use. + * @param options The plugin options to control the processing. + * @returns An esbuild OnLoaderResult object with the processed content, warnings, and/or errors. + */ +async function compileString( + data: string, + filename: string, + postcssProcessor: import('postcss').Processor, + options: StylesheetPluginOptions, +): Promise { + try { + const postcssResult = await postcssProcessor.process(data, { + from: filename, + to: filename, + map: options.sourcemap && { + inline: true, + sourcesContent: true, + }, + }); + + const loadResult: OnLoadResult = { + contents: postcssResult.css, + loader: 'css', + }; + + const rawWarnings = postcssResult.warnings(); + if (rawWarnings.length > 0) { + const lineMappings = new Map(); + loadResult.warnings = rawWarnings.map((warning) => { + const file = warning.node.source?.input.file; + if (file === undefined) { + return { text: warning.text }; + } + + let lines = lineMappings.get(file); + if (lines === undefined) { + lines = warning.node.source?.input.css.split(/\r?\n/); + lineMappings.set(file, lines ?? null); + } + + return { + text: warning.text, + location: { + file, + line: warning.line, + column: warning.column - 1, + lineText: lines?.[warning.line - 1], + }, + }; + }); + } + + for (const resultMessage of postcssResult.messages) { + if (resultMessage.type === 'dependency' && typeof resultMessage['file'] === 'string') { + loadResult.watchFiles ??= []; + loadResult.watchFiles.push(resultMessage['file']); + } else if ( + resultMessage.type === 'dir-dependency' && + typeof resultMessage['dir'] === 'string' && + typeof resultMessage['glob'] === 'string' + ) { + loadResult.watchFiles ??= []; + const dependencies = await glob(resultMessage['glob'], { + absolute: true, + cwd: resultMessage['dir'], + }); + loadResult.watchFiles.push(...dependencies); + } + } + + return loadResult; + } catch (error) { + postcss ??= (await import('postcss')).default; + if (error instanceof postcss.CssSyntaxError) { + const lines = error.source?.split(/\r?\n/); + + return { + errors: [ + { + text: error.reason, + location: { + file: error.file, + line: error.line, + column: error.column && error.column - 1, + lineText: error.line === undefined ? undefined : lines?.[error.line - 1], + }, + }, + ], + watchFiles: error.file && error.file !== filename ? [filename, error.file] : [filename], + }; + } else { + assertIsError(error); + + return { + errors: [ + { + text: error.message, + }, + ], + }; + } + } +} diff --git a/packages/angular/build/src/tools/esbuild/target.ts b/packages/angular/build/src/tools/esbuild/target.ts new file mode 100644 index 000000000000..2925905276b7 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/target.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { coerce, compare, minVersion } from 'semver'; + +/** + * Compares two target version strings. + * + * This function is used to determine the lowest version for a given browser target. + * + * @param a The first version string. + * @param b The second version string. + * @returns A negative value if `a` is lower than `b`, a positive value if `a` is higher than `b`, and 0 if they are equal. + */ +function compareTargetVersions(a: string, b: string): number { + const aVersion = coerce(a); + const bVersion = coerce(b); + + if (!aVersion || !bVersion) { + return aVersion ? -1 : bVersion ? 1 : 0; + } + + return compare(aVersion, bVersion); +} + +// https://esbuild.github.io/api/#target +const ESBUILD_SUPPORTED_BROWSERS: ReadonlySet = new Set([ + 'chrome', + 'edge', + 'firefox', + 'ie', + 'ios', + 'node', + 'opera', + 'safari', +]); + +/** + * Transform browserlists result to esbuild target. + * + * Only the lowest version for each browser is returned to avoid issues with esbuild and rolldown + * when multiple versions of the same target engine are specified. + * + * @see https://esbuild.github.io/api/#target + * @see https://github.com/evanw/esbuild/issues/4509 + * @see https://github.com/rolldown/rolldown/issues/10633 + */ +export function transformSupportedBrowsersToTargets(supportedBrowsers: string[]): string[] { + const browsers = new Map(); + + for (const browser of supportedBrowsers) { + let [browserName, version] = browser.toLowerCase().split(' '); + if (!browserName || !version) { + continue; + } + + // browserslist uses the name `ios_saf` for iOS Safari whereas esbuild uses `ios` + if (browserName === 'ios_saf') { + browserName = 'ios'; + } + + if (!ESBUILD_SUPPORTED_BROWSERS.has(browserName)) { + continue; + } + + // browserslist uses ranges `15.2-15.3` versions but only the lowest is required + // to perform minimum supported feature checks. esbuild also expects a single version. + [version] = version.split('-'); + + if (browserName === 'safari' && version === 'tp') { + // esbuild only supports numeric versions so `TP` is converted to a high number (999) since + // a Technology Preview (TP) of Safari is assumed to support all currently known features. + version = '999'; + } else if (!version.includes('.')) { + // A lone major version is considered by esbuild to include all minor versions. However, + // browserslist does not and is also inconsistent in its `.0` version naming. For example, + // Safari 15.0 is named `safari 15` but Safari 16.0 is named `safari 16.0`. + version += '.0'; + } + + const current = browsers.get(browserName); + if (!current || compareTargetVersions(version, current) < 0) { + browsers.set(browserName, version); + } + } + + return Array.from(browsers, ([browserName, version]) => browserName + version); +} + +const SUPPORTED_NODE_VERSIONS = '0.0.0-ENGINES-NODE'; + +/** + * Transform supported Node.js versions to esbuild target. + * + * Only the lowest Node.js version is returned to avoid issues with esbuild and rolldown + * when multiple versions of the same target engine are specified. + * + * @see https://esbuild.github.io/api/#target + * @see https://github.com/evanw/esbuild/issues/4509 + * @see https://github.com/rolldown/rolldown/issues/10633 + */ +export function getSupportedNodeTargets(): string[] { + if (SUPPORTED_NODE_VERSIONS.charAt(0) === '0') { + // Unlike `pkg_npm`, `ts_library` which is used to run unit tests does not support substitutions. + return []; + } + + const parsed = minVersion(SUPPORTED_NODE_VERSIONS); + + return parsed ? ['node' + parsed.version] : []; +} diff --git a/packages/angular/build/src/tools/esbuild/target_spec.ts b/packages/angular/build/src/tools/esbuild/target_spec.ts new file mode 100644 index 000000000000..e5a375a1412b --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/target_spec.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { getSupportedNodeTargets, transformSupportedBrowsersToTargets } from './target'; + +describe('esbuild target', () => { + describe('transformSupportedBrowsersToTargets', () => { + it('should return the smallest version for each browser', () => { + const targets = transformSupportedBrowsersToTargets([ + 'chrome 122', + 'chrome 120', + 'chrome 121', + 'firefox 116', + 'firefox 115', + 'safari 17.0', + 'safari 16.4', + ]); + + expect(targets).toEqual(['chrome120.0', 'firefox115.0', 'safari16.4']); + }); + + it('should handle version ranges and pick the lowest version', () => { + const targets = transformSupportedBrowsersToTargets([ + 'ios_saf 15.4', + 'ios_saf 15.2-15.3', + 'ios_saf 16.0', + ]); + + expect(targets).toEqual(['ios15.2']); + }); + + it('should handle Safari TP (Technology Preview)', () => { + const targetsWithOlderSafari = transformSupportedBrowsersToTargets([ + 'safari TP', + 'safari 16.4', + ]); + expect(targetsWithOlderSafari).toEqual(['safari16.4']); + + const targetsWithOnlyTP = transformSupportedBrowsersToTargets(['safari TP']); + expect(targetsWithOnlyTP).toEqual(['safari999']); + }); + + it('should ignore browsers not supported by esbuild', () => { + const targets = transformSupportedBrowsersToTargets([ + 'android 4.4', + 'samsung 22', + 'kaios 2.5', + 'chrome 115', + ]); + + expect(targets).toEqual(['chrome115.0']); + }); + + it('should return empty array for empty supportedBrowsers', () => { + const targets = transformSupportedBrowsersToTargets([]); + expect(targets).toEqual([]); + }); + + it('should handle malformed or incomplete browser strings gracefully', () => { + const targets = transformSupportedBrowsersToTargets(['chrome', 'firefox ', '']); + expect(targets).toEqual([]); + }); + + it('should handle single major versions by appending .0', () => { + const targets = transformSupportedBrowsersToTargets(['chrome 120', 'edge 120']); + expect(targets).toEqual(['chrome120.0', 'edge120.0']); + }); + }); + + describe('getSupportedNodeTargets', () => { + it('should return empty array when node versions are not stamped', () => { + expect(getSupportedNodeTargets()).toEqual([]); + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/utils.ts b/packages/angular/build/src/tools/esbuild/utils.ts new file mode 100644 index 000000000000..e93bb295b439 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/utils.ts @@ -0,0 +1,293 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderContext } from '@angular-devkit/architect'; +import { BuildOptions, Metafile, OutputFile, formatMessages } from 'esbuild'; +import { Listr } from 'listr2'; +import { basename, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { brotliCompress } from 'node:zlib'; +import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import { OutputMode } from '../../builders/application/schema'; +import { BudgetCalculatorResult } from '../../utils/bundle-calculator'; + +import { BundleStats, generateEsbuildBuildStatsTable } from '../../utils/stats-table'; +import { ExecutionResult, PrerenderedRoutesRecord } from './bundler-execution-result'; +import { type BuildOutputFile, BuildOutputFileType, type InitialFileRecord } from './bundler-files'; + +export function logBuildStats( + metafiles: Metafile[], + outputFiles: BuildOutputFile[], + initial: Map, + budgetFailures: BudgetCalculatorResult[] | undefined, + colors: boolean, + changedFiles?: Set, + estimatedTransferSizes?: Map, + ssrOutputEnabled?: boolean, + verbose?: boolean, +): string { + // Remove the i18n subpath in case the build is using i18n. + // en-US/main.js -> main.js + const normalizedChangedFiles: Set = new Set( + [...(changedFiles ?? [])].map((f) => basename(f)), + ); + const browserStats: BundleStats[] = []; + const serverStats: BundleStats[] = []; + let unchangedCount = 0; + let componentStyleChange = false; + + for (const { path: file, size, type } of outputFiles) { + // Only display JavaScript and CSS files + if (!/\.(?:css|m?js)$/.test(file)) { + continue; + } + + // Show only changed files if a changed list is provided + if (normalizedChangedFiles.size && !normalizedChangedFiles.has(file)) { + ++unchangedCount; + continue; + } + + const isPlatformServer = + type === BuildOutputFileType.ServerApplication || type === BuildOutputFileType.ServerRoot; + if (isPlatformServer && !ssrOutputEnabled) { + // Only log server build stats when SSR is enabled. + continue; + } + + // Skip logging external component stylesheets used for HMR + if (metafiles.some((mf) => mf.outputs[file] && 'ng-component' in mf.outputs[file])) { + componentStyleChange = true; + continue; + } + + const name = initial.get(file)?.name ?? getChunkNameFromMetafile(metafiles, file); + const stat: BundleStats = { + initial: initial.has(file), + stats: [file, name ?? '-', size, estimatedTransferSizes?.get(file) ?? '-'], + }; + + if (isPlatformServer) { + serverStats.push(stat); + } else { + browserStats.push(stat); + } + } + + if (browserStats.length > 0 || serverStats.length > 0) { + const tableText = generateEsbuildBuildStatsTable( + [browserStats, serverStats], + colors, + unchangedCount === 0, + !!estimatedTransferSizes, + budgetFailures, + verbose, + ); + + return tableText + '\n'; + } else if (changedFiles !== undefined) { + if (componentStyleChange) { + return '\nComponent stylesheet(s) changed.\n'; + } else { + return '\nNo output file changes.\n'; + } + } + if (unchangedCount > 0) { + return `Unchanged output files: ${unchangedCount}`; + } + + return ''; +} + +export function getChunkNameFromMetafile( + metafiles: Metafile[] | Metafile, + file: string, +): string | undefined { + const metafileArray = Array.isArray(metafiles) ? metafiles : [metafiles]; + for (const metafile of metafileArray) { + if (metafile.outputs[file]?.entryPoint) { + return getEntryPointName(metafile.outputs[file].entryPoint); + } + } +} + +export async function calculateEstimatedTransferSizes( + outputFiles: OutputFile[], +): Promise> { + const sizes = new Map(); + if (outputFiles.length <= 0) { + return sizes; + } + + return new Promise((resolve, reject) => { + let completeCount = 0; + for (const outputFile of outputFiles) { + // Only calculate JavaScript and CSS files + if (!outputFile.path.endsWith('.js') && !outputFile.path.endsWith('.css')) { + ++completeCount; + continue; + } + + // Skip compressing small files which may end being larger once compressed and will most likely not be + // compressed in actual transit. + if (outputFile.contents.byteLength < 1024) { + sizes.set(outputFile.path, outputFile.contents.byteLength); + ++completeCount; + continue; + } + + // Directly use the async callback function to minimize the number of Promises that need to be created. + brotliCompress(outputFile.contents, (error, result) => { + if (error) { + reject(error); + + return; + } + + sizes.set(outputFile.path, result.byteLength); + if (++completeCount >= outputFiles.length) { + resolve(sizes); + } + }); + } + + // Covers the case where no files need to be compressed + if (completeCount >= outputFiles.length) { + resolve(sizes); + } + }); +} + +export async function withSpinner(text: string, action: () => T | Promise): Promise { + let result; + const taskList = new Listr( + [ + { + title: text, + async task() { + result = await action(); + }, + }, + ], + { rendererOptions: { clearOutput: true } }, + ); + + await taskList.run(); + + return result as T; +} + +export async function withNoProgress(text: string, action: () => T | Promise): Promise { + return action(); +} + +/** + * Generates a syntax feature object map for Angular applications. + * A full set of feature names can be found here: https://esbuild.github.io/api/#supported + * @param nativeAsyncAwait Indicate whether to support native async/await. + * @returns An object that can be used with the esbuild build `supported` option. + */ +export function getFeatureSupport(nativeAsyncAwait: boolean): BuildOptions['supported'] { + return { + // Native async/await is not supported with Zone.js. Disabling support here will cause + // esbuild to downlevel async/await, async generators, and for await...of to a Zone.js supported form. + 'async-await': nativeAsyncAwait, + // Workaround for an esbuild minification bug when async-await is disabled and the target is es2019+. + // The catch binding for downleveled for-await will be incorrectly removed in this specific situation. + ...(!nativeAsyncAwait ? { 'optional-catch-binding': false } : {}), + // V8 currently has a performance defect involving object spread operations that can cause signficant + // degradation in runtime performance. By not supporting the language feature here, a downlevel form + // will be used instead which provides a workaround for the performance issue. + // For more details: https://bugs.chromium.org/p/v8/issues/detail?id=11536 + 'object-rest-spread': false, + }; +} + +interface BuildManifest { + errors: string[]; + warnings: string[]; + outputPaths: { + root: URL; + server?: URL | undefined; + browser: URL; + }; + prerenderedRoutes: PrerenderedRoutesRecord; +} + +export async function createJsonBuildManifest( + result: ExecutionResult, + normalizedOptions: NormalizedApplicationBuildOptions, +): Promise { + const { + colors: color, + outputOptions: { base, server, browser }, + ssrOptions, + outputMode, + } = normalizedOptions; + + const { warnings, errors, prerenderedRoutes } = result; + + const manifest: BuildManifest = { + errors: errors.length ? await formatMessages(errors, { kind: 'error', color }) : [], + warnings: warnings.length ? await formatMessages(warnings, { kind: 'warning', color }) : [], + outputPaths: { + root: pathToFileURL(base), + browser: pathToFileURL(join(base, browser)), + server: + outputMode !== OutputMode.Static && ssrOptions + ? pathToFileURL(join(base, server)) + : undefined, + }, + prerenderedRoutes, + }; + + return JSON.stringify(manifest, undefined, 2); +} + +export async function logMessages( + logger: BuilderContext['logger'], + executionResult: ExecutionResult, + color?: boolean, + jsonLogs?: boolean, +): Promise { + const { warnings, errors, logs } = executionResult; + + if (logs.length) { + logger.info(logs.join('\n')); + } + + if (jsonLogs) { + return; + } + + if (warnings.length) { + logger.warn((await formatMessages(warnings, { kind: 'warning', color })).join('\n')); + } + + if (errors.length) { + logger.error((await formatMessages(errors, { kind: 'error', color })).join('\n')); + } +} + +/** + * Ascertain whether the application operates without `zone.js`, we currently rely on the polyfills setting to determine its status. + * If a file with an extension is provided or if `zone.js` is included in the polyfills, the application is deemed as not zoneless. + * @param polyfills An array of polyfills + * @returns true, when the application is considered as zoneless. + */ +export function isZonelessApp(polyfills: string[] | undefined): boolean { + // TODO: Instead, we should rely on the presence of zone.js in the polyfills build metadata. + return !polyfills?.some((p) => p === 'zone.js' || /\.[mc]?[jt]s$/.test(p)); +} + +export function getEntryPointName(entryPoint: string): string { + return basename(entryPoint) + .replace(/(.*:)/, '') // global:bundle.css -> bundle.css + .replace(/\.[cm]?[jt]s$/, '') + .replace(/[\\/.]/g, '-'); +} diff --git a/packages/angular/build/src/tools/esbuild/virtual-module-plugin.ts b/packages/angular/build/src/tools/esbuild/virtual-module-plugin.ts new file mode 100644 index 000000000000..7f6b3729e3fb --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/virtual-module-plugin.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { OnLoadArgs, Plugin, PluginBuild } from 'esbuild'; +import { LoadResultCache, createCachedLoad } from './load-result-cache'; + +/** + * Options for the createVirtualModulePlugin + * @see createVirtualModulePlugin + */ +export interface VirtualModulePluginOptions { + /** Namespace. Example: `angular:polyfills`. */ + namespace: string; + + /** If the generated module should be marked as external. */ + external?: boolean; + + /** Method to transform the onResolve path. */ + transformPath?: (path: string) => string; + + /** Method to provide the module content. */ + loadContent: ( + args: OnLoadArgs, + build: PluginBuild, + ) => ReturnType[1]>; + + /** Restrict to only entry points. Defaults to `true`. */ + entryPointOnly?: boolean; + + /** Load results cache. */ + cache?: LoadResultCache; +} + +/** + * Creates an esbuild plugin that generated virtual modules. + * + * @returns An esbuild plugin. + */ +export function createVirtualModulePlugin(options: VirtualModulePluginOptions): Plugin { + const { + namespace, + external, + transformPath: pathTransformer, + loadContent, + cache, + entryPointOnly = true, + } = options; + + return { + name: namespace.replace(/[/:]/g, '-'), + setup(build): void { + build.onResolve({ filter: new RegExp('^' + namespace) }, ({ kind, path }) => { + if (entryPointOnly && kind !== 'entry-point') { + return null; + } + + return { + path: pathTransformer?.(path) ?? path, + namespace, + }; + }); + + if (external) { + build.onResolve({ filter: /./, namespace }, ({ path }) => { + return { + path, + external: true, + }; + }); + } + + build.onLoad( + { filter: /./, namespace }, + createCachedLoad(cache, (args) => loadContent(args, build)), + ); + }, + }; +} diff --git a/packages/angular/build/src/tools/esbuild/wasm-plugin.ts b/packages/angular/build/src/tools/esbuild/wasm-plugin.ts new file mode 100644 index 000000000000..1bc18dd747b2 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/wasm-plugin.ts @@ -0,0 +1,257 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Plugin, ResolveOptions } from 'esbuild'; +import assert from 'node:assert'; +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; +import { assertIsError } from '../../utils/error'; +import { LoadResultCache, createCachedLoad } from './load-result-cache'; + +/** + * Options for the Angular WASM esbuild plugin + * @see createWasmPlugin + */ +export interface WasmPluginOptions { + /** Allow generation of async (proposal compliant) WASM imports. This requires zoneless to enable async/await. */ + allowAsync?: boolean; + + /** Load results cache. */ + cache?: LoadResultCache; +} + +const WASM_INIT_NAMESPACE = 'angular:wasm:init'; +const WASM_CONTENTS_NAMESPACE = 'angular:wasm:contents'; +const WASM_RESOLVE_SYMBOL = Symbol('WASM_RESOLVE_SYMBOL'); + +// See: https://github.com/tc39/proposal-regexp-unicode-property-escapes/blob/fe6d07fad74cd0192d154966baa1e95e7cda78a1/README.md#other-examples +const ecmaIdentifierNameRegExp = /^(?:[$_\p{ID_Start}])(?:[$_\u200C\u200D\p{ID_Continue}])*$/u; + +/** + * Creates an esbuild plugin to use WASM files with import statements and expressions. + * The default behavior follows the WebAssembly/ES mode integration proposal found at + * https://github.com/WebAssembly/esm-integration/tree/main/proposals/esm-integration. + * This behavior requires top-level await support which is only available in zoneless + * Angular applications. + * @returns An esbuild plugin. + */ +export function createWasmPlugin(options: WasmPluginOptions): Plugin { + const { allowAsync = false, cache } = options; + + return { + name: 'angular-wasm', + setup(build): void { + build.onResolve({ filter: /\.wasm$/ }, async (args) => { + // Skip if already resolving the WASM file to avoid infinite resolution + if (args.pluginData?.[WASM_RESOLVE_SYMBOL]) { + return; + } + // Skip if not an import statement or expression + if (args.kind !== 'import-statement' && args.kind !== 'dynamic-import') { + return; + } + + // When in the initialization namespace, the content has already been resolved + // and only needs to be loaded for use with the initialization code. + if (args.namespace === WASM_INIT_NAMESPACE) { + return { + namespace: WASM_CONTENTS_NAMESPACE, + path: join(args.resolveDir, args.path), + pluginData: args.pluginData, + }; + } + + // Skip if a custom loader is defined + if (build.initialOptions.loader?.['.wasm'] || args.with['loader']) { + return; + } + + // Attempt full resolution of the WASM file + const resolveOptions: ResolveOptions & { path?: string } = { + ...args, + pluginData: { [WASM_RESOLVE_SYMBOL]: true }, + }; + // The "path" property will cause an error if used in the resolve call + delete resolveOptions.path; + + const result = await build.resolve(args.path, resolveOptions); + + // Skip if there are errors, is external, or another plugin resolves to a custom namespace + if (result.errors.length > 0 || result.external || result.namespace !== 'file') { + // Reuse already resolved result + return result; + } + + return { + ...result, + namespace: WASM_INIT_NAMESPACE, + }; + }); + + build.onLoad( + { filter: /\.wasm$/, namespace: WASM_INIT_NAMESPACE }, + createCachedLoad(cache, async (args) => { + // Ensure async mode is supported + if (!allowAsync) { + return { + errors: [ + { + text: 'WASM/ES module integration imports are not supported with Zone.js applications', + notes: [ + { + text: 'Information about zoneless Angular applications can be found here: https://angular.dev/guide/experimental/zoneless', + }, + ], + }, + ], + }; + } + + const wasmContents = await readFile(args.path); + // Inline WASM code less than 10kB + const inlineWasm = wasmContents.byteLength < 10_000; + + // Add import of WASM contents + let initContents = `import ${inlineWasm ? 'wasmData' : 'wasmPath'} from ${JSON.stringify(basename(args.path))}`; + initContents += inlineWasm ? ' with { loader: "binary" };' : ';\n\n'; + + // Read from the file system when on Node.js (SSR) and not inline + if (!inlineWasm && build.initialOptions.platform === 'node') { + initContents += 'import { readFile } from "node:fs/promises";\n'; + initContents += + 'const wasmData = await readFile(new URL(wasmPath, import.meta.url));\n'; + } + + // Create initialization function + initContents += generateInitHelper( + !inlineWasm && build.initialOptions.platform !== 'node', + wasmContents, + ); + + // Analyze WASM for imports and exports + let importModuleNames, exportNames; + try { + const wasm = await WebAssembly.compile(wasmContents); + importModuleNames = new Set( + WebAssembly.Module.imports(wasm).map((value) => value.module), + ); + exportNames = WebAssembly.Module.exports(wasm).map((value) => value.name); + } catch (error) { + assertIsError(error); + + return { + errors: [{ text: 'Unable to analyze WASM file', notes: [{ text: error.message }] }], + }; + } + + // Ensure export names are valid JavaScript identifiers + const invalidExportNames = exportNames.filter( + (name) => !ecmaIdentifierNameRegExp.test(name), + ); + if (invalidExportNames.length > 0) { + return { + errors: invalidExportNames.map((name) => ({ + text: 'WASM export names must be valid JavaScript identifiers', + notes: [ + { + text: `The export "${name}" is not valid. The WASM file should be updated to remove this error.`, + }, + ], + })), + }; + } + + // Add import statements and setup import object + initContents += 'const importObject = Object.create(null);\n'; + let importIndex = 0; + for (const moduleName of importModuleNames) { + // Add a namespace import for each module name + initContents += `import * as wasm_import_${++importIndex} from ${JSON.stringify(moduleName)};\n`; + // Add the namespace object to the import object + initContents += `importObject[${JSON.stringify(moduleName)}] = wasm_import_${importIndex};\n`; + } + + // Instantiate the module + initContents += 'const instance = await init(importObject);\n'; + + // Add exports + const exportNameList = exportNames.join(', '); + initContents += `const { ${exportNameList} } = instance.exports;\n`; + initContents += `export { ${exportNameList} }\n`; + + return { + contents: initContents, + loader: 'js', + resolveDir: dirname(args.path), + pluginData: { wasmContents }, + watchFiles: [args.path], + }; + }), + ); + + build.onLoad({ filter: /\.wasm$/, namespace: WASM_CONTENTS_NAMESPACE }, async (args) => { + const contents = args.pluginData.wasmContents ?? (await readFile(args.path)); + + let loader: 'binary' | 'file' = 'file'; + if (args.with.loader) { + assert( + args.with.loader === 'binary' || args.with.loader === 'file', + 'WASM loader type should only be binary or file.', + ); + loader = args.with.loader; + } + + return { + contents, + loader, + watchFiles: [args.path], + }; + }); + }, + }; +} + +/** + * Generates the string content of the WASM initialization helper function. + * This function supports both file fetching and inline byte data depending on + * the preferred option for the WASM file. When fetching, an integrity hash is + * also generated and used with the fetch action. + * + * @param streaming Uses fetch and WebAssembly.instantiateStreaming. + * @param wasmContents The binary contents to generate an integrity hash. + * @returns A string containing the initialization function. + */ +function generateInitHelper(streaming: boolean, wasmContents: Uint8Array) { + let resultContents; + if (streaming) { + const fetchOptions = { + integrity: 'sha256-' + createHash('sha256').update(wasmContents).digest('base64'), + }; + const fetchContents = `fetch(new URL(wasmPath, import.meta.url), ${JSON.stringify(fetchOptions)})`; + resultContents = `await WebAssembly.instantiateStreaming(${fetchContents}, imports)`; + } else { + resultContents = 'await WebAssembly.instantiate(wasmData, imports)'; + } + + const contents = ` +let mod; +async function init(imports) { + if (mod) { + return await WebAssembly.instantiate(mod, imports); + } + + const result = ${resultContents}; + mod = result.module; + + return result.instance; +} +`; + + return contents; +} diff --git a/packages/angular/build/src/tools/esbuild/wasm.d.ts b/packages/angular/build/src/tools/esbuild/wasm.d.ts new file mode 100644 index 000000000000..e3b45032eccc --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/wasm.d.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** @fileoverview + * TypeScript does not provide a separate lib for WASM types and the Node.js + * types (`@types/node`) does not contain them either. This type definition + * file provides type information for the subset of functionality required + * by the Angular build process. Ideally this can be removed when the WASM + * type situation has improved. + */ + +declare namespace WebAssembly { + class Module { + constructor(data: Uint8Array); + + static imports(mod: Module): { module: string; name: string }[]; + static exports(mode: Module): { name: string }[]; + } + function compile(data: Uint8Array): Promise; +} diff --git a/packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts b/packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts new file mode 100644 index 000000000000..a84d54eb713b --- /dev/null +++ b/packages/angular/build/src/tools/i18n/i18n-inliner-worker.ts @@ -0,0 +1,582 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import '../oxc/oxc-wasi-shim'; +import { type DecodedSourceMap, type SourceMapInput } from '@ampproject/remapping'; +import { + type ɵParsedTranslation, + ɵisMissingTranslationError, + ɵmakeTemplateObject, + ɵparseMessage, + ɵtranslate, +} from '@angular/localize'; +import { MagicString } from 'magic-string'; +import { deserialize } from 'node:v8'; +import { parseSync } from 'oxc-parser'; +import { traversePostOrder } from '../oxc/traversal'; +import { createSharedTranslationProxy } from './i18n-translation-reader'; +import { loadLocaleData } from './locale-data'; + +/** + * The options passed to the inliner for each code request + */ +export interface InlineCodeRequest { + /** + * The code that should be processed. + */ + code: string; + + /** + * The filename to use in error and warning messages for the provided code. + */ + filename: string; + + /** + * The locale specifier that should be used during the inlining process of the file. + */ + locale: string; + + /** + * The serialized translation messages for the locale that should be used during the inlining + * process of the file. A SharedArrayBuffer or Blob is used so that the messages are shared with + * the Worker by reference instead of being copied into it for every request. + */ + translation?: Blob | SharedArrayBuffer; + + /** + * How to handle missing translations. + */ + missingTranslation?: 'error' | 'warning' | 'ignore'; +} + +/** + * The response returned from a code request. + */ +export interface InlineCodeResult { + output: string; + messages: { type: 'error' | 'warning'; message: string }[]; +} + +/** + * The options passed to the inliner for a batch file request + */ +export interface InlineFileBatchRequest { + /** + * The filename that should be processed. + */ + filename: string; + + /** + * The file content as a Blob. + */ + code: Blob; + + /** + * Optional sourcemap content as a Blob. + */ + map?: Blob; + + /** + * The locale specifiers and optional translations to use during the inlining process of the file. + */ + locales: ReadonlyMap; + + /** + * How to handle missing translations. + */ + missingTranslation?: 'error' | 'warning' | 'ignore'; + + /** + * Whether the file data should be treated as ephemeral and not cached long-term in the Worker. + * Typically true when all remaining locales for the file are processed in a single batch. + */ + ephemeral?: boolean; + + /** + * The list of active locales in the current inlining window. Any cached translation dictionaries + * not present in this list will be evicted from the Worker's memory cache. + */ + activeLocales?: string[]; + + /** + * The current inlining generation counter. When a request with a new generation is received, + * all long-term worker caches are cleared. + */ + generation?: number; +} + +/** + * The result for a single locale within a batch file request. + */ +export interface InlineLocaleResult { + locale: string; + code?: string; + map?: string; + messages: { type: 'error' | 'warning'; message: string }[]; +} + +/** + * The response returned from a batch file request. + */ +export type InlineFileBatchResult = + | { + file: string; + unmodified: true; + messages: { type: 'error' | 'warning'; message: string }[]; + } + | { + file: string; + unmodified?: false; + results: InlineLocaleResult[]; + }; + +/** + * Cached file data including code and extracted localization metadata. + */ +interface CachedFileData { + code: string; + metadata: FileLocalizeMetadata; +} + +/** + * Cache of file data promises keyed by filename. + */ +const fileDataCache = new Map>(); + +/** + * Cache of deserialized translation messages keyed by locale. + */ +const deserializedTranslations = new Map>>(); + +/** + * The current inlining generation for this worker. + */ +let currentGeneration: number | undefined; + +let remapping: typeof import('@ampproject/remapping').default | undefined; + +/** + * Retrieves the file data for a filename, loading and extracting localization metadata. + * If `cache` is true, the result is cached in `fileDataCache` across requests in this Worker. + * If `cache` is false (ephemeral), the result is not retained in `fileDataCache`, allowing it + * to be garbage-collected once the batch request finishes. + * + * @param filename The name of the file to load. + * @param codeBlob The source code file as a Blob. + * @param cache Whether to cache the loaded file data in the Worker's long-term cache. + * @returns The cached or newly extracted code and localization metadata. + */ +function loadFileData(filename: string, codeBlob: Blob, cache = true): Promise { + const existing = fileDataCache.get(filename); + if (existing) { + if (!cache) { + fileDataCache.delete(filename); + } + + return existing; + } + + const fileDataPromise = (async () => { + const code = await codeBlob.text(); + const metadata = extractLocalizeMetadata(filename, code); + + return { code, metadata }; + })(); + + if (cache) { + fileDataPromise.catch(() => { + fileDataCache.delete(filename); + }); + fileDataCache.set(filename, fileDataPromise); + } + + return fileDataPromise; +} + +/** + * Deserializes or wraps the translation messages for a locale, reusing the result for any + * subsequent request that targets the same locale. + * @param locale The locale identifier. + * @param translation Optional serialized translation messages (SharedArrayBuffer or Blob). + * @returns The translation messages, or undefined if the locale has no translations. + */ +function loadTranslation( + locale: string, + translation?: Blob | SharedArrayBuffer, +): Promise> | undefined { + if (!translation) { + return undefined; + } + + let messagesPromise = deserializedTranslations.get(locale); + if (!messagesPromise) { + if (translation instanceof Blob) { + messagesPromise = translation + .arrayBuffer() + .then((buffer) => deserialize(new Uint8Array(buffer)) as Record) + .catch((error) => { + deserializedTranslations.delete(locale); + throw error; + }); + } else { + messagesPromise = Promise.resolve(createSharedTranslationProxy(translation)); + } + deserializedTranslations.set(locale, messagesPromise); + } + + return messagesPromise; +} + +/** + * Inlines multiple locales and translations into a JavaScript file that contains `$localize` usage. + * + * @param request An InlineFileBatchRequest object representing the options for inlining. + * @returns An object containing the inlined results for each requested locale. + */ +export async function inlineFileBatch( + request: InlineFileBatchRequest, +): Promise { + if (request.generation !== undefined && request.generation !== currentGeneration) { + currentGeneration = request.generation; + fileDataCache.clear(); + deserializedTranslations.clear(); + } + + if (request.activeLocales) { + const activeSet = new Set(request.activeLocales); + for (const locale of deserializedTranslations.keys()) { + if (!activeSet.has(locale)) { + deserializedTranslations.delete(locale); + } + } + } + + const { code, metadata } = await loadFileData(request.filename, request.code, !request.ephemeral); + + // Fast path: file has no $localize call sites or locale insert sites + if (metadata.callSites.length === 0 && metadata.localeInsertSites.length === 0) { + return { + file: request.filename, + unmodified: true, + messages: (metadata.diagnostics ?? []).map((message) => ({ + type: 'error' as const, + message, + })), + }; + } + + // Parse the sourcemap once for the entire batch if provided. + // It will naturally be garbage-collected after this batch action returns. + let map: SourceMapInput | undefined; + if (request.map) { + const rawMap = await request.map.text(); + map = rawMap ? (JSON.parse(rawMap) as SourceMapInput) : undefined; + } + + const results = []; + for (const [locale, translation] of request.locales) { + const result = await inlineLocalize( + code, + map, + metadata, + locale, + await loadTranslation(locale, translation), + request.filename, + request.missingTranslation, + ); + + results.push({ + locale, + code: result.code, + map: result.map, + messages: result.diagnostics, + }); + } + + return { + file: request.filename, + results, + }; +} + +/** + * Inlines the provided locale and translation into JavaScript code that contains `$localize` usage. + * This function is a secondary entry primarily for use with component HMR update modules. + * + * @param request An InlineRequest object representing the options for inlining + * @returns An object containing the inlined code. + */ +export async function inlineCode(request: InlineCodeRequest): Promise { + const metadata = extractLocalizeMetadata(request.filename, request.code); + const result = await inlineLocalize( + request.code, + undefined, + metadata, + request.locale, + await loadTranslation(request.locale, request.translation), + request.filename, + request.missingTranslation, + ); + + return { + output: result.code ?? request.code, + messages: result.diagnostics, + }; +} + +type DiagnosticMessage = { type: 'error' | 'warning'; message: string }; + +/** + * Translates a $localize message using @angular/localize low-level runtime functions. + * Handles missing translations and errors without requiring @angular/localize/tools. + */ +function translateMessage( + diagnostics: DiagnosticMessage[], + translations: Record | undefined, + messageParts: TemplateStringsArray, + substitutions: readonly number[], + missingTranslation: 'error' | 'warning' | 'ignore', +): [TemplateStringsArray, readonly number[]] { + // Fast path: untranslated locale (e.g. source locale without a translation dictionary). + // Directly parse the message parts to strip metadata and placeholder markers without + // throwing and catching a MissingTranslationError exception. + if (translations === undefined) { + const message = ɵparseMessage(messageParts, substitutions); + + return [ɵmakeTemplateObject(message.messageParts, message.messageParts), substitutions]; + } + + try { + return ɵtranslate(translations, messageParts, substitutions) as [ + TemplateStringsArray, + readonly number[], + ]; + } catch (error) { + if (ɵisMissingTranslationError(error)) { + if (missingTranslation !== 'ignore') { + diagnostics.push({ type: missingTranslation, message: error.message }); + } + + return [ + ɵmakeTemplateObject(error.parsedMessage.messageParts, error.parsedMessage.messageParts), + substitutions, + ]; + } else { + const message = error instanceof Error ? error.message : String(error); + diagnostics.push({ type: 'error', message }); + + return [messageParts, substitutions]; + } + } +} + +/** + * Metadata for a `$localize` tagged template expression extracted from the AST. + */ +interface LocalizeCallSite { + start: number; + end: number; + messageParts: TemplateStringsArray; + expressions: { start: number; end: number }[]; + expressionIndexes: number[]; +} + +/** + * Metadata extracted from a JavaScript file AST needed for localization inlining. + */ +interface FileLocalizeMetadata { + callSites: LocalizeCallSite[]; + localeInsertSites: { start: number; end: number }[]; + diagnostics?: string[]; +} + +/** + * Extracts localization call sites and locale insertion points from JavaScript code using OXC. + * + * @param filename The name of the file being processed. + * @param code The JavaScript source code. + * @returns The extracted localization metadata. + */ +function extractLocalizeMetadata(filename: string, code: string): FileLocalizeMetadata { + const { program } = parseSync(filename, code, { + sourceType: 'unambiguous', + }); + + if (!program) { + throw new Error(`Unknown error occurred parsing file "${filename}" with OXC.`); + } + + const callSites: LocalizeCallSite[] = []; + const localeInsertSites: { start: number; end: number }[] = []; + let diagnostics: string[] | undefined; + + traversePostOrder(program, (node) => { + if (node.type === 'Literal') { + if (typeof node.value === 'string' && node.value === '___NG_LOCALE_INSERT___') { + localeInsertSites.push({ start: node.start, end: node.end }); + } + } else if (node.type === 'TaggedTemplateExpression') { + if (node.tag.type === 'Identifier' && node.tag.name === '$localize') { + const cooked: string[] = []; + const raw: string[] = []; + let hasMalformedEscape = false; + + for (const q of node.quasi.quasis) { + if (q.value.cooked === null || q.value.cooked === undefined) { + hasMalformedEscape = true; + (diagnostics ??= []).push( + `Malformed escape sequence in $localize template literal in file "${filename}".`, + ); + break; + } + cooked.push(q.value.cooked); + raw.push(q.value.raw); + } + + if (!hasMalformedEscape) { + const messageParts = Object.assign(cooked, { raw }); + const expressions = node.quasi.expressions.map((expr) => ({ + start: expr.start, + end: expr.end, + })); + const expressionIndexes = expressions.map((_, index) => index); + + callSites.push({ + start: node.start, + end: node.end, + messageParts, + expressions, + expressionIndexes, + }); + } + } + } + }); + + return { callSites, localeInsertSites, diagnostics }; +} + +/** + * Escapes a template literal string part for insertion into an ES template literal (backticks). + * Uses JSON.stringify for base escaping of control characters and backslashes, then unescapes + * double quotes and escapes backticks and `${` expression delimiters in a single pass. + */ +function escapeTemplatePart(part: string): string { + return JSON.stringify(part) + .slice(1, -1) + .replace(/\\"|`|\$\{/g, (match) => (match === '\\"' ? '"' : '\\' + match)); +} + +/** + * Inlines translations into code using previously extracted localization metadata. + * + * @param code The source code to transform. + * @param map Optional source map for the source code. + * @param metadata Extracted localization metadata. + * @param locale The target locale identifier. + * @param translation The translation messages dictionary, or undefined for untranslated locale. + * @param filename The name of the file being transformed. + * @param missingTranslation How to handle missing translations. + * @returns The transformed code, optional remapped source map, and diagnostics. + */ +async function inlineLocalize( + code: string, + map: SourceMapInput | undefined, + metadata: FileLocalizeMetadata, + locale: string, + translation: Record | undefined, + filename: string, + missingTranslation: 'error' | 'warning' | 'ignore' = 'warning', +) { + const magicString = new MagicString(code); + const diagnostics: DiagnosticMessage[] = []; + + if (metadata.diagnostics) { + for (const message of metadata.diagnostics) { + diagnostics.push({ type: 'error', message }); + } + } + + if (metadata.localeInsertSites.length > 0) { + const localeData = await loadLocaleData(locale); + if (localeData.error) { + diagnostics.push({ type: 'error', message: localeData.error }); + } else if (localeData.warning) { + diagnostics.push({ type: 'warning', message: localeData.warning }); + } + let injected = false; + for (const site of metadata.localeInsertSites) { + magicString.overwrite( + site.start, + site.end, + JSON.stringify(locale) + (localeData.code && !injected ? `;\n${localeData.code}\n;` : ''), + ); + injected = true; + } + } + + for (const callSite of metadata.callSites) { + const [translatedParts, translatedSubstitutions] = translateMessage( + diagnostics, + translation, + callSite.messageParts, + callSite.expressionIndexes, + translation === undefined ? 'ignore' : missingTranslation, + ); + + // Reconstruct the new template/string literal replacement + let replacement: string; + if (translatedSubstitutions.length === 0) { + replacement = JSON.stringify(translatedParts[0]); + } else { + replacement = '`'; + for (let i = 0; i < translatedParts.length; i++) { + replacement += escapeTemplatePart(translatedParts[i]); + + if (i < translatedSubstitutions.length) { + const originalIndex = translatedSubstitutions[i]; + const expr = callSite.expressions[originalIndex]; + const exprCode = magicString.slice(expr.start, expr.end); + replacement += '${' + exprCode + '}'; + } + } + replacement += '`'; + } + + magicString.overwrite(callSite.start, callSite.end, replacement); + } + + if (!magicString.hasChanged()) { + return { + code: undefined, + map: undefined, + diagnostics, + }; + } + + const outputCode = magicString.toString(); + let outputMap; + if (map) { + // A decoded map is generated here rather than an encoded one because remapping decodes its + // inputs. Encoding the mappings only for remapping to immediately decode them again doubles + // the peak memory of the largest structure involved in inlining a file. + const rawMap = magicString.generateDecodedMap({ + source: filename, + includeContent: true, + hires: 'boundary', + }); + remapping ??= (await import('@ampproject/remapping')).default; + outputMap = remapping([{ ...rawMap, version: 3 } satisfies DecodedSourceMap, map], () => null); + } + + return { + code: outputCode, + map: outputMap && JSON.stringify(outputMap), + diagnostics, + }; +} diff --git a/packages/angular/build/src/tools/i18n/i18n-inliner.ts b/packages/angular/build/src/tools/i18n/i18n-inliner.ts new file mode 100644 index 000000000000..d4a4c4cd631f --- /dev/null +++ b/packages/angular/build/src/tools/i18n/i18n-inliner.ts @@ -0,0 +1,749 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { ɵParsedTranslation } from '@angular/localize'; +import assert from 'node:assert'; +import { createRequire } from 'node:module'; +import { extname, join } from 'node:path'; +import { serialize } from 'node:v8'; +import { + type Cache, + type PersistentCacheStore, + createPersistentCacheStore, +} from '../../utils/cache'; +import { calculateHash, createContentHash, initializeHash } from '../../utils/hash'; +import { WorkerPool } from '../../utils/worker-pool'; +import { + type BuildOutputFile, + BuildOutputFileType, + createOutputFile, +} from '../esbuild/bundler-files'; +import type { + InlineCodeRequest, + InlineCodeResult, + InlineFileBatchRequest, + InlineFileBatchResult, +} from './i18n-inliner-worker'; +import { encodeTranslationToBuffer } from './i18n-translation-encoder'; + +// TODO: Convert to import.meta usage during ESM transition +const localRequire = createRequire(__filename); +const INLINER_WORKER_PATH = localRequire.resolve('./i18n-inliner-worker'); + +interface WorkerTaskMap { + inlineFileBatch: { + request: InlineFileBatchRequest; + result: InlineFileBatchResult; + }; + inlineCode: { + request: InlineCodeRequest; + result: InlineCodeResult; + }; +} + +/** + * A keyword used to indicate if a JavaScript file may require inlining of translations. + * This keyword is used to avoid processing files that would not otherwise need i18n processing. + */ +const LOCALIZE_KEYWORD = '$localize'; + +/** + * The baseline number of locales to process concurrently in a single sliding window. + * This caps peak worker memory on low-core machines while maintaining multi-locale batching throughput. + */ +const DEFAULT_LOCALE_WINDOW_SIZE = 8; + +/** + * Minimum byte size threshold for a file to be eligible for multi-batch sharding. + * Files below this threshold (< 100 KB) are processed in a single batch to minimize IPC overhead. + */ +const SMALL_FILE_FLOOR_BYTES = 100 * 1024; + +/** + * Ratio of the maximum file size in a window to consider a file "dominant". + * Files within 70% of the largest file are sharded across bounded workers for concurrency. + */ +const DOMINANT_FILE_RATIO = 0.7; + +/** + * Maximum number of worker threads to allocate concurrently across dominant files. + * Capping concurrency on dominant files (which hold large AST and sourcemap trees) + * prevents V8 heap and RSS explosion on machines with high CPU core counts. + */ +const MAX_DOMINANT_WORKERS = 4; + +/** + * Serializes the translation messages for a locale for transfer to an inliner Worker. + * + * A SharedArrayBuffer is preferred because it enables zero-copy shared memory access + * and on-demand string decoding across all worker threads. Falls back to a Blob when + * SharedArrayBuffer is unavailable. + * + * @param translation The translation messages for a locale, if the locale has any. + * @param translationIntegrity Optional content hash of the translation file for cache lookup. + * @param translationCache Optional Cache instance for binary translation buffers. + * @returns A SharedArrayBuffer or Blob containing the serialized messages, or undefined if none. + */ +async function serializeTranslation( + translation: Record | undefined, + translationIntegrity?: string, + translationCache?: Cache, +): Promise { + if (!translation) { + return undefined; + } + + if (typeof SharedArrayBuffer !== 'undefined') { + if (translationIntegrity && translationCache) { + // Look up or generate binary translation data in the persistent cache. + // A Uint8Array view is stored in the cache store to allow binary persistence. + const binaryData = await translationCache.getOrCreate(translationIntegrity, () => { + return new Uint8Array(encodeTranslationToBuffer(translation)); + }); + + // On a cache miss, getOrCreate returns the newly created Uint8Array backed by the + // original SharedArrayBuffer. Return it directly to avoid an unnecessary allocation and copy. + if ( + binaryData.buffer instanceof SharedArrayBuffer && + binaryData.byteOffset === 0 && + binaryData.byteLength === binaryData.buffer.byteLength + ) { + return binaryData.buffer; + } + + // On a warm cache hit, the restored data is backed by a standard ArrayBuffer from disk. + // Copy it into a SharedArrayBuffer so worker threads can access it via zero-copy shared memory. + const buffer = new SharedArrayBuffer(binaryData.byteLength); + new Uint8Array(buffer).set(binaryData); + + return buffer; + } + + // When persistent caching is not configured, encode directly into a SharedArrayBuffer. + return encodeTranslationToBuffer(translation); + } + + return new Blob([serialize(translation)]); +} + +/** + * Inlining options that should apply to all transformed code. + */ +export interface I18nInlinerOptions { + missingTranslation: 'error' | 'warning' | 'ignore'; + + /** + * The maximum number of concurrent translation inlining operations. + * When omitted, concurrency defaults to the available worker pool threads. + */ + maxConcurrency?: number; + persistentCachePath?: string; + localizeVersion?: string; +} + +/** + * Options for inlining a specific locale. + */ +export interface LocaleInlineOptions { + /** + * The locale specifier string. + */ + locale: string; + + /** + * The translation messages for the locale, or undefined for the source/untranslated locale. + */ + translation?: Record; + + /** + * An optional content integrity hash of the translation file(s) for fast cache key calculation. + */ + translationIntegrity?: string; +} + +/** + * Result of inlining for a specific locale. + */ +export interface LocaleInlineResult { + outputFiles: BuildOutputFile[]; + errors: string[]; + warnings: string[]; +} + +/** + * Transformation result for a single file and locale combination. + */ +interface TransformedFileResult { + file: string; + code?: string; + map?: string; + messages: { type: 'error' | 'warning'; message: string }[]; +} + +/** + * An uncached transformation request entry for a file within a specific locale. + */ +interface UncachedLocaleEntry { + locale: string; + cacheKey?: string; + translation?: Blob | SharedArrayBuffer; +} + +/** + * A class that performs i18n translation inlining of JavaScript code. + * A worker pool is used to distribute the transformation actions and allow + * parallel processing. Inlining is only performed on code that contains the + * localize function (`$localize`). + */ +export class I18nInliner { + #cacheInitFailed = false; + readonly #workerPool: WorkerPool; + readonly #ownsWorkerPool: boolean; + #cacheStore: PersistentCacheStore | undefined; + #transformedFileCache: Cache | undefined; + #translationCache: Cache | undefined; + #generation = 0; + + get #maxConcurrency(): number { + return this.options.maxConcurrency ?? (this.#workerPool.maxThreads || 1); + } + + constructor( + private readonly options: I18nInlinerOptions, + workerPool?: WorkerPool, + ) { + if ( + options.maxConcurrency !== undefined && + (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency < 1) + ) { + throw new RangeError('options.maxConcurrency must be an integer greater than or equal to 1.'); + } + + this.#ownsWorkerPool = !workerPool; + // Piscina uses object spread against default options internally. Only define + // maxThreads when specified to avoid overwriting Piscina's default thread count + // with undefined. + this.#workerPool = + workerPool ?? + new WorkerPool({ + ...(options.maxConcurrency !== undefined && { maxThreads: options.maxConcurrency }), + }); + } + + #partitionFiles(files: Iterable): { + filenames: string[]; + localizeFiles: Map; + localizeMaps: Map; + unmodifiedFiles: BuildOutputFile[]; + } { + const filenames: string[] = []; + const localizeFiles = new Map(); + const localizeMaps = new Map(); + const unmodifiedFiles: BuildOutputFile[] = []; + + const pendingMaps: BuildOutputFile[] = []; + for (const file of files) { + if (file.type === BuildOutputFileType.Root || file.type === BuildOutputFileType.ServerRoot) { + // Skip also the server entry-point. + // Skip stats and similar files. + continue; + } + + const fileExtension = extname(file.path); + if (fileExtension === '.js' || fileExtension === '.mjs') { + // Check if localizations are present + const contentBuffer = Buffer.isBuffer(file.contents) + ? file.contents + : Buffer.from(file.contents.buffer, file.contents.byteOffset, file.contents.byteLength); + const hasLocalize = contentBuffer.includes(LOCALIZE_KEYWORD); + + if (hasLocalize) { + localizeFiles.set(file.path, file); + filenames.push(file.path); + + continue; + } + } else if (fileExtension === '.map') { + // The related JS file may not have been checked yet. To ensure that map files are not + // missed, store any pending map files and check them after all output files. + pendingMaps.push(file); + continue; + } + + unmodifiedFiles.push(file); + } + + // Check if any pending map files should be processed by checking if the parent JS file is present + for (const file of pendingMaps) { + const jsPath = file.path.slice(0, -4); + if (localizeFiles.has(jsPath)) { + localizeMaps.set(jsPath, file); + } else { + unmodifiedFiles.push(file); + } + } + + return { filenames, localizeFiles, localizeMaps, unmodifiedFiles }; + } + + /** + * Performs inlining of translations across multiple locales in parallel. + * + * An adaptive 2D task-partitioning algorithm distributes (files x locales) work units + * across all worker threads while caching AST metadata and sourcemaps in worker memory. + * + * @param files The build output files to transform. + * @param locales The locales and translations to inline. + * @returns A map of locale names to their inlined output files and diagnostics. + */ + async inlineAll( + files: Iterable, + locales: Iterable, + ): Promise> { + await this.initCache(); + + const generation = ++this.#generation; + const { missingTranslation, localizeVersion } = this.options; + const localeList = Array.from(locales); + + if (localeList.length === 0) { + return new Map(); + } + + const { filenames, localizeFiles, localizeMaps, unmodifiedFiles } = this.#partitionFiles(files); + + const fileResultsByLocale = new Map>(); + for (const { locale } of localeList) { + assert(!fileResultsByLocale.has(locale), 'Duplicate locale provided to inliner: ' + locale); + fileResultsByLocale.set(locale, new Map()); + } + + // Process locales in sliding windows to cap peak worker memory. + // Ensure the window has at least enough locales to saturate all available workers on high-core machines. + const windowSize = Math.max(DEFAULT_LOCALE_WINDOW_SIZE, this.#maxConcurrency); + for (let i = 0; i < localeList.length; i += windowSize) { + const windowLocales = localeList.slice(i, i + windowSize); + const activeLocales = windowLocales.map((item) => item.locale); + const isLastWindow = i + windowSize >= localeList.length; + + // Pre-calculate cache key bases and serialized Blobs for each locale in this window + const localeCacheBases = new Map(); + const localeBlobs = new Map(); + + await Promise.all( + windowLocales.map(async ({ locale, translation, translationIntegrity }) => { + const serialized = await serializeTranslation( + translation, + translationIntegrity, + this.#translationCache, + ); + localeBlobs.set(locale, serialized); + + if (this.#cacheStore) { + localeCacheBases.set( + locale, + calculateHash( + JSON.stringify({ + locale, + translation: translationIntegrity || translation, + missingTranslation, + localizeVersion, + }), + ), + ); + } + }), + ); + + const uncachedByFile = new Map(); + + if (this.#transformedFileCache) { + const cache = this.#transformedFileCache; + const cacheChecks: Promise[] = []; + + for (const filename of filenames) { + const file = localizeFiles.get(filename); + assert(file !== undefined, 'Localize file must exist: ' + filename); + + const fileEntriesPromises = windowLocales.map( + async ({ locale }): Promise => { + const fileCacheKeyBase = localeCacheBases.get(locale); + assert(fileCacheKeyBase !== undefined, 'Cache base must exist for locale: ' + locale); + + const hasher = createContentHash(); + hasher.update(file.hash); + hasher.update(filename); + hasher.update(fileCacheKeyBase); + const cacheKey = hasher.digest(); + + try { + const result = await cache.get(cacheKey); + if (result) { + fileResultsByLocale.get(locale)?.set(filename, result); + + return; + } + } catch {} + + return { + locale, + cacheKey, + translation: localeBlobs.get(locale), + }; + }, + ); + + cacheChecks.push( + Promise.all(fileEntriesPromises).then((entries) => { + const filtered = entries.filter((e): e is UncachedLocaleEntry => e !== undefined); + if (filtered.length > 0) { + uncachedByFile.set(filename, filtered); + } + }), + ); + } + + await Promise.all(cacheChecks); + } else { + for (const filename of filenames) { + uncachedByFile.set( + filename, + windowLocales.map(({ locale }) => ({ + locale, + translation: localeBlobs.get(locale), + })), + ); + } + } + + // Adaptive 2D Sharding for uncached tasks in this window + if (uncachedByFile.size > 0) { + await this.#processUncachedBatches( + localizeFiles, + localizeMaps, + uncachedByFile, + fileResultsByLocale, + activeLocales, + isLastWindow, + generation, + ); + } + } + + // Assemble final results in deterministic order per locale + const resultsByLocale = new Map(); + + for (const { locale } of localeList) { + const fileResults = fileResultsByLocale.get(locale); + const outputFiles: BuildOutputFile[] = []; + const errors: string[] = []; + const warnings: string[] = []; + + if (fileResults) { + for (const filename of filenames) { + const originalFile = localizeFiles.get(filename); + assert(originalFile !== undefined, 'Localize file must exist: ' + filename); + + const fileResult = fileResults.get(filename); + if (!fileResult) { + continue; + } + + const type = originalFile.type; + if (fileResult.code != undefined) { + outputFiles.push(createOutputFile(filename, fileResult.code, type)); + } else { + outputFiles.push(originalFile.clone()); + } + + const originalMap = localizeMaps.get(filename); + if (fileResult.map !== undefined) { + outputFiles.push(createOutputFile(filename + '.map', fileResult.map, type)); + } else if (originalMap !== undefined) { + outputFiles.push(originalMap.clone()); + } + + for (const message of fileResult.messages) { + if (message.type === 'error') { + errors.push(message.message); + } else { + warnings.push(message.message); + } + } + } + } + + // Include cloned unmodified files for every locale + outputFiles.push(...unmodifiedFiles.map((file) => file.clone())); + + resultsByLocale.set(locale, { + outputFiles, + errors, + warnings, + }); + } + + return resultsByLocale; + } + + async #processUncachedBatches( + localizeFiles: Map, + localizeMaps: Map, + uncachedByFile: Map, + fileResultsByLocale: Map>, + activeLocales?: string[], + isLastWindow = true, + generation?: number, + ): Promise { + const workerCount = this.#maxConcurrency; + + // Extract file data and identify the heaviest file size in a single pass + let maxFileSize = 0; + const sortedFiles = Array.from(uncachedByFile, ([filename, entries]) => { + const codeFile = localizeFiles.get(filename); + assert(codeFile !== undefined, 'Localize file must exist: ' + filename); + const fileSize = codeFile.contents.byteLength; + if (fileSize > maxFileSize) { + maxFileSize = fileSize; + } + + return { filename, entries, codeFile, fileSize }; + }); + + // Sort files descending by byte size (Longest Processing Time First / LPT). + // Heavy files (e.g. main.js) are queued first to saturate all worker threads immediately, + // while small files act as gap fillers near the window barrier to prevent tail stragglers. + sortedFiles.sort((a, b) => b.fileSize - a.fileSize); + + const dominantThreshold = Math.max(SMALL_FILE_FLOOR_BYTES, maxFileSize * DOMINANT_FILE_RATIO); + let dominantCount = 0; + for (const { fileSize } of sortedFiles) { + if (fileSize >= dominantThreshold) { + dominantCount++; + } else { + break; + } + } + const dominantTargetWorkers = + dominantCount > 0 + ? Math.max(1, Math.min(workerCount - 1, Math.floor(MAX_DOMINANT_WORKERS / dominantCount))) + : 1; + + const workerTasks: Promise[] = []; + + for (const { filename, entries, codeFile, fileSize } of sortedFiles) { + const mapFile = localizeMaps.get(filename); + const codeBlob = new Blob([codeFile.contents]); + const mapBlob = mapFile ? new Blob([mapFile.contents]) : undefined; + + let localesPerBatch: number; + if (uncachedByFile.size === 1) { + // Single file in window: shard across bounded workers to balance throughput and peak memory + const targetWorkers = Math.min(workerCount, MAX_DOMINANT_WORKERS); + localesPerBatch = Math.max(1, Math.ceil(entries.length / targetWorkers)); + } else if (fileSize < SMALL_FILE_FLOOR_BYTES) { + // Small chunks (< 100 KB): process all locales in 1 batch to eliminate IPC overhead + localesPerBatch = entries.length; + } else if (fileSize >= dominantThreshold) { + // Dominant file(s) with multiple files: shard across bounded workers while always reserving + // at least one worker to process smaller chunks in parallel without starvation. + localesPerBatch = Math.max(1, Math.ceil(entries.length / dominantTargetWorkers)); + } else if (uncachedByFile.size >= workerCount) { + // Worker pool is already saturated by file-level concurrency; + // process all locales in a single batch to avoid duplicate parsing and IPC overhead. + localesPerBatch = entries.length; + } else { + // Intermediate files with idle workers: moderate sharding + localesPerBatch = Math.max(1, Math.ceil(entries.length / 2)); + } + + const ephemeral = isLastWindow && entries.length <= localesPerBatch; + for (let i = 0; i < entries.length; i += localesPerBatch) { + const batchEntries = entries.slice(i, i + localesPerBatch); + const task = (async () => { + const batchResult = await this.#runWorkerTask('inlineFileBatch', { + filename, + code: codeBlob, + map: mapBlob, + locales: new Map(batchEntries.map((e) => [e.locale, e.translation])), + missingTranslation: this.options.missingTranslation, + ephemeral, + activeLocales, + generation, + }); + + if (batchResult.unmodified) { + const unmodifiedResult: TransformedFileResult = { + file: filename, + messages: batchResult.messages, + }; + + const cachePromises: Promise[] = []; + for (const { locale, cacheKey } of batchEntries) { + fileResultsByLocale.get(locale)?.set(filename, unmodifiedResult); + + if (this.#transformedFileCache && cacheKey) { + cachePromises.push(this.#transformedFileCache.put(cacheKey, unmodifiedResult)); + } + } + await Promise.allSettled(cachePromises); + } else { + const cachePromises: Promise[] = []; + for (const res of batchResult.results) { + const matchingEntry = batchEntries.find((e) => e.locale === res.locale); + const cacheKey = matchingEntry?.cacheKey; + const fileResult: TransformedFileResult = { + file: filename, + code: res.code, + map: res.map, + messages: res.messages, + }; + + if (this.#transformedFileCache && cacheKey) { + cachePromises.push(this.#transformedFileCache.put(cacheKey, fileResult)); + } + + fileResultsByLocale.get(res.locale)?.set(filename, fileResult); + } + await Promise.allSettled(cachePromises); + } + })(); + + workerTasks.push(task); + } + } + + await Promise.all(workerTasks); + } + + #runWorkerTask( + name: T, + request: WorkerTaskMap[T]['request'], + ): Promise { + return this.#workerPool.run(request, { + filename: INLINER_WORKER_PATH, + name, + }) as Promise; + } + + /** + * Performs inlining of translations for the provided locale and translations. + * + * @param files The build output files to transform. + * @param locale The string representing the locale to inline. + * @param translation The translation messages to use when inlining. + * @param translationIntegrity An optional integrity value for the translation messages to use for caching. + * @returns A promise that resolves to an array of OutputFiles representing a translated result. + */ + async inlineForLocale( + files: Iterable, + locale: string, + translation: Record | undefined, + translationIntegrity?: string, + ): Promise { + const results = await this.inlineAll(files, [{ locale, translation, translationIntegrity }]); + const result = results.get(locale); + assert(result !== undefined, `Result for locale '${locale}' should be present.`); + + return result; + } + + async inlineTemplateUpdate( + locale: string, + translation: Record | undefined, + templateCode: string, + templateId: string, + translationIntegrity?: string, + ): Promise<{ code: string; errors: string[]; warnings: string[] }> { + const hasLocalize = templateCode.includes(LOCALIZE_KEYWORD); + + if (!hasLocalize) { + return { + code: templateCode, + errors: [], + warnings: [], + }; + } + + const { output, messages } = await this.#runWorkerTask('inlineCode', { + code: templateCode, + filename: templateId, + locale, + missingTranslation: this.options.missingTranslation, + translation: await serializeTranslation( + translation, + translationIntegrity, + this.#translationCache, + ), + }); + + const errors: string[] = []; + const warnings: string[] = []; + for (const message of messages) { + if (message.type === 'error') { + errors.push(message.message); + } else { + warnings.push(message.message); + } + } + + return { + code: output, + errors, + warnings, + }; + } + + /** + * Stops all active transformation tasks and shuts down all workers. + * @returns A void promise that resolves when closing is complete. + */ + async close(): Promise { + await Promise.allSettled([ + this.#cacheStore?.close(), + this.#ownsWorkerPool ? this.#workerPool.destroy() : undefined, + ]); + } + + /** + * Initializes the cache for storing translated bundles. + * If the cache is already initialized, it does nothing. + * + * @returns A promise that resolves once the cache initialization process is complete. + */ + private async initCache(): Promise { + if (this.#cacheStore || this.#cacheInitFailed) { + return; + } + + const { persistentCachePath } = this.options; + // Webcontainers currently do not support this persistent cache store. + if (!persistentCachePath || process.versions.webcontainer) { + return; + } + + // Initialize a persistent cache for i18n transformations. + try { + const [, cacheStore] = await Promise.all([ + initializeHash(), + createPersistentCacheStore(join(persistentCachePath, 'angular-i18n')), + ]); + this.#cacheStore = cacheStore; + this.#transformedFileCache = cacheStore.createCache('transforms'); + this.#translationCache = cacheStore.createCache('translations'); + } catch { + this.#cacheInitFailed = true; + + // eslint-disable-next-line no-console + console.warn( + 'Unable to initialize JavaScript cache storage.\n' + + 'This will not affect the build output content but may result in slower builds.', + ); + } + } +} diff --git a/packages/angular/build/src/tools/i18n/i18n-inliner_spec.ts b/packages/angular/build/src/tools/i18n/i18n-inliner_spec.ts new file mode 100644 index 000000000000..2fbb6e02ce31 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/i18n-inliner_spec.ts @@ -0,0 +1,1187 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { ɵParsedTranslation } from '@angular/localize'; +import { transform } from 'esbuild'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createPersistentCacheStore } from '../../utils/cache'; +import { initializeHash } from '../../utils/hash'; +import { WorkerPool } from '../../utils/worker-pool'; +import { + type BuildOutputFile, + BuildOutputFileType, + createOutputFile, +} from '../esbuild/bundler-files'; +import { I18nInliner, type I18nInlinerOptions } from './i18n-inliner'; + +/** + * A module that uses a `$localize` message with an explicit message identifier so that the + * translations for a test can be keyed by a known name. + */ +const GREETING_SOURCE = 'export const greeting = $localize`:@@greeting:Hello`;\n'; + +/** + * Creates the parsed translation form that `@angular/localize` expects. + */ +function parsedTranslation( + parts: string[], + placeholderNames: string[] = [], + text?: string, +): ɵParsedTranslation { + return { + messageParts: Object.assign([...parts], { raw: [...parts] }), + placeholderNames, + text: text ?? parts.join(''), + }; +} + +function translationFor(message: string): ɵParsedTranslation { + return parsedTranslation([message], [], message); +} + +function browserFile(path: string, contents: string): BuildOutputFile { + return createOutputFile(path, contents, BuildOutputFileType.Browser); +} + +function findFile(outputFiles: BuildOutputFile[], path: string): BuildOutputFile { + const file = outputFiles.find((output) => output.path === path); + if (!file) { + throw new Error(`Expected output files to contain '${path}'.`); + } + + return file; +} + +describe('I18nInliner', () => { + let inliner: I18nInliner | undefined; + + beforeAll(async () => { + await initializeHash(); + }); + + // A single thread is used throughout so that every file of every locale is inlined by the same + // Worker. Any translation state that a Worker retains between requests is then observable. + function createInliner(options?: Partial, maxConcurrency = 1): I18nInliner { + inliner = new I18nInliner({ missingTranslation: 'warning', maxConcurrency, ...options }); + + return inliner; + } + + afterEach(async () => { + await inliner?.close(); + inliner = undefined; + }); + + it('inlines the translations of a locale', async () => { + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', GREETING_SOURCE)], + 'fr', + { greeting: translationFor('Bonjour') }, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize'); + }); + + it('inlines the translations of each locale when several are inlined in sequence', async () => { + const files = [browserFile('main.js', GREETING_SOURCE)]; + const localeInliner = createInliner(); + + const french = await localeInliner.inlineForLocale(files, 'fr', { + greeting: translationFor('Bonjour'), + }); + const german = await localeInliner.inlineForLocale(files, 'de', { + greeting: translationFor('Hallo'), + }); + // Repeats the first locale to cover a locale being inlined again after another has been. + const frenchAgain = await localeInliner.inlineForLocale(files, 'fr', { + greeting: translationFor('Bonjour'), + }); + + expect(findFile(french.outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(german.outputFiles, 'main.js').text).toContain('"Hallo"'); + expect(findFile(frenchAgain.outputFiles, 'main.js').text).toContain('"Bonjour"'); + }); + + it('inlines the translations of a locale into every file that uses them', async () => { + const { outputFiles } = await createInliner().inlineForLocale( + [browserFile('main.js', GREETING_SOURCE), browserFile('chunk.js', GREETING_SOURCE)], + 'fr', + { greeting: translationFor('Bonjour') }, + ); + + expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(outputFiles, 'chunk.js').text).toContain('"Bonjour"'); + }); + + it('retains the original messages for a locale without translations', async () => { + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', GREETING_SOURCE)], + 'en-US', + undefined, + ); + + // A locale without translations is the source locale, so its messages are not missing. + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"'); + }); + + it('retains the original messages with placeholders for a locale without translations', async () => { + const source = 'export const greeting = $localize`:@@greeting:Hello ${name}:NAME:!`;\n'; + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', source)], + 'en-US', + undefined, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('`Hello ${name}!`'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize'); + }); + + it('warns and retains the original message when a locale is missing a translation', async () => { + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', GREETING_SOURCE)], + 'fr', + { unrelated: translationFor('Sans rapport') }, + ); + + expect(errors).toEqual([]); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain('greeting'); + expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"'); + }); + + it('errors and retains the original message when missingTranslation is "error"', async () => { + const { outputFiles, errors, warnings } = await createInliner({ + missingTranslation: 'error', + }).inlineForLocale([browserFile('main.js', GREETING_SOURCE)], 'fr', { + unrelated: translationFor('Sans rapport'), + }); + + expect(errors.length).toBe(1); + expect(errors[0]).toContain('greeting'); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"'); + }); + + it('ignores missing translations when missingTranslation is "ignore"', async () => { + const { outputFiles, errors, warnings } = await createInliner({ + missingTranslation: 'ignore', + }).inlineForLocale([browserFile('main.js', GREETING_SOURCE)], 'fr', { + unrelated: translationFor('Sans rapport'), + }); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"'); + }); + + it('replaces the locale placeholder with the locale being inlined', async () => { + // The placeholder is only inlined for files that use `$localize`, which is where the build + // inserts it, so the message is present alongside it here. + const { outputFiles } = await createInliner().inlineForLocale( + [ + browserFile( + 'main.js', + `export const locale = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`, + ), + ], + 'fr', + { greeting: translationFor('Bonjour') }, + ); + + expect(findFile(outputFiles, 'main.js').text).toContain('"fr"'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('___NG_LOCALE_INSERT___'); + }); + + it('remaps the source map of a file it modifies', async () => { + // esbuild provides a map from the emitted code back to an original file, matching what the + // inliner receives during a build. + const { code, map } = await transform(GREETING_SOURCE, { + sourcefile: 'greeting.ts', + loader: 'ts', + sourcemap: 'external', + }); + + const { outputFiles } = await createInliner().inlineForLocale( + [browserFile('main.js', code), browserFile('main.js.map', map)], + 'fr', + { greeting: translationFor('Bonjour') }, + ); + + const outputMap = JSON.parse(findFile(outputFiles, 'main.js.map').text) as { + version: number; + sources: string[]; + mappings: string; + }; + + expect(outputMap.version).toBe(3); + // The map must still resolve to the original file rather than to the inliner's input. + expect(outputMap.sources).toContain('greeting.ts'); + expect(outputMap.mappings.length).toBeGreaterThan(0); + }); + + describe('inlineTemplateUpdate', () => { + it('inlines the translations of a locale into a template update', async () => { + const { code, errors, warnings } = await createInliner().inlineTemplateUpdate( + 'fr', + { greeting: translationFor('Bonjour') }, + GREETING_SOURCE, + 'template-id', + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(code).toContain('"Bonjour"'); + expect(code).not.toContain('$localize'); + }); + + it('retains the original messages for a locale without translations', async () => { + const { code, errors, warnings } = await createInliner().inlineTemplateUpdate( + 'en-US', + undefined, + GREETING_SOURCE, + 'template-id', + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(code).toContain('"Hello"'); + }); + + it('returns the code untouched when it has no localize calls', async () => { + const source = 'export const answer = 42;\n'; + const { code } = await createInliner().inlineTemplateUpdate( + 'fr', + { greeting: translationFor('Bonjour') }, + source, + 'template-id', + ); + + expect(code).toBe(source); + }); + + it('inlines translations with translationIntegrity and persistent caching', async () => { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18n-template-cache-test-')); + + try { + const inliner = new I18nInliner({ + missingTranslation: 'error', + persistentCachePath: cacheDir, + }); + + const translationIntegrity = 'hash-template-integrity-123'; + const { code, errors, warnings } = await inliner.inlineTemplateUpdate( + 'fr', + { greeting: translationFor('Bonjour') }, + GREETING_SOURCE, + 'template-id', + translationIntegrity, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(code).toContain('"Bonjour"'); + await inliner.close(); + } finally { + await fs.rm(cacheDir, { recursive: true, force: true }); + } + }); + }); + + it('safely inlines translations containing special characters, quotes, and newlines', async () => { + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', GREETING_SOURCE)], + 'fr', + { + greeting: translationFor('Bonjour "mon ami" \\ \' \n '), + }, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toBe( + 'export const greeting = "Bonjour \\"mon ami\\" \\\\ \' \\n ";\n', + ); + }); + + it('inlines translations containing placeholders', async () => { + const source = 'export const welcome = (name) => $localize`:@@welcome:Hello ${name}!`;\n'; + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', source)], + 'fr', + { + welcome: parsedTranslation(['Bonjour ', ' !'], ['PH'], 'Bonjour {$PH} !'), + }, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toBe( + 'export const welcome = (name) => `Bonjour ${name} !`;\n', + ); + }); + + it('inlines multiple localize calls within the same file', async () => { + const source = + 'export const a = $localize`:@@greeting:Hello`;\nexport const b = $localize`:@@farewell:Goodbye`;\n'; + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', source)], + 'fr', + { + greeting: translationFor('Bonjour'), + farewell: translationFor('Au revoir'), + }, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toBe( + 'export const a = "Bonjour";\nexport const b = "Au revoir";\n', + ); + }); + + it('inlines translations across multiple files using multiple worker threads in parallel', async () => { + const files = [ + browserFile('main.js', GREETING_SOURCE), + browserFile('chunk1.js', GREETING_SOURCE), + browserFile('chunk2.js', GREETING_SOURCE), + browserFile('chunk3.js', GREETING_SOURCE), + ]; + inliner = new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: 4, + }); + + const { outputFiles, errors, warnings } = await inliner.inlineForLocale(files, 'fr', { + greeting: translationFor('Bonjour'), + }); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + for (const name of ['main.js', 'chunk1.js', 'chunk2.js', 'chunk3.js']) { + expect(findFile(outputFiles, name).text).toBe('export const greeting = "Bonjour";\n'); + } + }); + + it('leaves files without localize calls unmodified', async () => { + const { outputFiles } = await createInliner().inlineForLocale( + [ + browserFile('main.js', GREETING_SOURCE), + browserFile('other.js', 'export const answer = 42;\n'), + ], + 'fr', + { greeting: translationFor('Bonjour') }, + ); + + expect(findFile(outputFiles, 'other.js').text).toBe('export const answer = 42;\n'); + }); + + it('inlines nested $localize calls in post-order', async () => { + const source = + 'export const msg = $localize`:@@outer:You selected ${$localize`:@@inner:Apple`} for delivery.`;\n'; + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', source)], + 'fr', + { + inner: translationFor('Pomme'), + outer: parsedTranslation( + ['Vous avez sélectionné ', ' pour la livraison.'], + ['PH'], + 'Vous avez sélectionné {$PH} pour la livraison.', + ), + }, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toBe( + 'export const msg = `Vous avez sélectionné ${"Pomme"} pour la livraison.`;\n', + ); + }); + + it('reports an error diagnostic when a $localize template has a malformed escape sequence', async () => { + const source = 'export const msg = $localize`:@@id:\\unicode:`;\n'; + const { errors } = await createInliner().inlineForLocale( + [browserFile('main.js', source)], + 'fr', + {}, + ); + + expect(errors).toEqual([ + 'Malformed escape sequence in $localize template literal in file "main.js".', + ]); + }); + + it('inlines the translations of a locale when translationIntegrity is provided', async () => { + const { outputFiles, errors, warnings } = await createInliner().inlineForLocale( + [browserFile('main.js', GREETING_SOURCE)], + 'fr', + { greeting: translationFor('Bonjour') }, + 'sha256-test-integrity', + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize'); + }); + + it('inlines the translations of a locale when localizeVersion is configured in options', async () => { + inliner = new I18nInliner({ + missingTranslation: 'warning', + localizeVersion: '20.2.0', + maxConcurrency: 1, + }); + + const { outputFiles, errors, warnings } = await inliner.inlineForLocale( + [browserFile('main.js', GREETING_SOURCE)], + 'fr', + { greeting: translationFor('Bonjour') }, + 'sha256-test-integrity', + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize'); + }); + + it('inlines multiple locales in parallel via inlineAll', async () => { + const files = [ + browserFile('main.js', GREETING_SOURCE), + browserFile('chunk.js', GREETING_SOURCE), + browserFile('other.js', 'export const answer = 42;\n'), + ]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + { locale: 'de', translation: { greeting: translationFor('Hallo') } }, + { locale: 'es', translation: { greeting: translationFor('Hola') } }, + { locale: 'en-US', translation: undefined }, + ]); + + expect(results.size).toBe(4); + + const fr = results.get('fr'); + expect(fr).toBeDefined(); + expect(fr?.errors).toEqual([]); + expect(fr?.warnings).toEqual([]); + expect(findFile(fr?.outputFiles ?? [], 'main.js').text).toContain('"Bonjour"'); + expect(findFile(fr?.outputFiles ?? [], 'chunk.js').text).toContain('"Bonjour"'); + expect(findFile(fr?.outputFiles ?? [], 'other.js').text).toBe('export const answer = 42;\n'); + + const de = results.get('de'); + expect(de).toBeDefined(); + expect(de?.errors).toEqual([]); + expect(findFile(de?.outputFiles ?? [], 'main.js').text).toContain('"Hallo"'); + + const es = results.get('es'); + expect(es).toBeDefined(); + expect(es?.errors).toEqual([]); + expect(findFile(es?.outputFiles ?? [], 'main.js').text).toContain('"Hola"'); + + const en = results.get('en-US'); + expect(en).toBeDefined(); + expect(en?.errors).toEqual([]); + expect(findFile(en?.outputFiles ?? [], 'main.js').text).toContain('"Hello"'); + }); + + it('inlines multiple locales with sourcemaps in parallel via inlineAll', async () => { + const { code, map } = await transform(GREETING_SOURCE, { + sourcefile: 'greeting.ts', + loader: 'ts', + sourcemap: 'external', + }); + + const files = [ + browserFile('main.js', code), + browserFile('main.js.map', map), + browserFile('other.js', 'export const answer = 42;\n'), + ]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + { locale: 'de', translation: { greeting: translationFor('Hallo') } }, + { locale: 'en-US', translation: undefined }, + ]); + + expect(results.size).toBe(3); + + for (const [locale, greeting] of [ + ['fr', 'Bonjour'], + ['de', 'Hallo'], + ['en-US', 'Hello'], + ] as const) { + const localeResult = results.get(locale); + expect(localeResult).toBeDefined(); + expect(localeResult?.errors).toEqual([]); + expect(localeResult?.warnings).toEqual([]); + + const mainJs = findFile(localeResult?.outputFiles ?? [], 'main.js'); + expect(mainJs.text).toContain(`"${greeting}"`); + + const mainMap = findFile(localeResult?.outputFiles ?? [], 'main.js.map'); + const outputMap = JSON.parse(mainMap.text) as { + version: number; + sources: string[]; + mappings: string; + }; + expect(outputMap.version).toBe(3); + expect(outputMap.sources).toContain('greeting.ts'); + expect(outputMap.mappings.length).toBeGreaterThan(0); + + const otherJs = findFile(localeResult?.outputFiles ?? [], 'other.js'); + expect(otherJs.text).toBe('export const answer = 42;\n'); + } + }); + + it('inlines multiple locales with partial cache hits and misses via inlineAll', async () => { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18n-cache-test-')); + + try { + const files = [ + browserFile('main.js', GREETING_SOURCE), + browserFile('other.js', 'export const answer = 42;\n'), + ]; + const initialInliner = new I18nInliner({ + missingTranslation: 'warning', + persistentCachePath: cacheDir, + maxConcurrency: 2, + }); + + // Pre-populate cache for 'fr' + await initialInliner.inlineForLocale( + files, + 'fr', + { greeting: translationFor('Bonjour') }, + 'integrity-fr-1', + ); + await initialInliner.close(); + + // Create new inliner with same cache path, inlining cached 'fr' alongside uncached 'de' and 'es' + inliner = new I18nInliner({ + missingTranslation: 'warning', + persistentCachePath: cacheDir, + maxConcurrency: 2, + }); + + const results = await inliner.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + translationIntegrity: 'integrity-fr-1', + }, + { + locale: 'de', + translation: { greeting: translationFor('Hallo') }, + translationIntegrity: 'integrity-de-1', + }, + { + locale: 'es', + translation: { greeting: translationFor('Hola') }, + translationIntegrity: 'integrity-es-1', + }, + ]); + + expect(results.size).toBe(3); + + const fr = results.get('fr'); + expect(fr?.errors).toEqual([]); + expect(findFile(fr?.outputFiles ?? [], 'main.js').text).toContain('"Bonjour"'); + expect(findFile(fr?.outputFiles ?? [], 'other.js').text).toBe('export const answer = 42;\n'); + + const de = results.get('de'); + expect(de?.errors).toEqual([]); + expect(findFile(de?.outputFiles ?? [], 'main.js').text).toContain('"Hallo"'); + + const es = results.get('es'); + expect(es?.errors).toEqual([]); + expect(findFile(es?.outputFiles ?? [], 'main.js').text).toContain('"Hola"'); + + // Verify deterministic file order matching input order + for (const localeResult of results.values()) { + expect(localeResult.outputFiles.map((f) => f.path)).toEqual(['main.js', 'other.js']); + } + } finally { + await fs.rm(cacheDir, { recursive: true, force: true }); + } + }); + + it('inlines across sliding windows when locale count exceeds window size', async () => { + const locales = Array.from({ length: 20 }, (_, i) => ({ + locale: `locale-${i}`, + translation: { greeting: translationFor(`Hello ${i}`) }, + })); + + const files = [browserFile('main.js', GREETING_SOURCE)]; + inliner = new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: 2, + }); + + const results = await inliner.inlineAll(files, locales); + + expect(results.size).toBe(20); + for (let i = 0; i < 20; i++) { + const localeResult = results.get(`locale-${i}`); + expect(localeResult?.errors).toEqual([]); + expect(findFile(localeResult?.outputFiles ?? [], 'main.js').text).toContain(`"Hello ${i}"`); + } + }); + + it('injects locale data alongside ___NG_LOCALE_INSERT___ for non-English locales', async () => { + const source = `(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`; + const files = [browserFile('polyfills.js', source)]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + { locale: 'en-US', translation: undefined }, + ]); + + const fr = results.get('fr'); + expect(fr?.errors).toEqual([]); + const frPolyfills = findFile(fr?.outputFiles ?? [], 'polyfills.js').text; + expect(frPolyfills).toContain('(globalThis.$localize ??= {}).locale = "fr";'); + expect(frPolyfills).toContain('ng.common.locales'); + + const en = results.get('en-US'); + expect(en?.errors).toEqual([]); + const enPolyfills = findFile(en?.outputFiles ?? [], 'polyfills.js').text; + expect(enPolyfills).toContain('(globalThis.$localize ??= {}).locale = "en-US";'); + expect(enPolyfills).not.toContain('ng.common.locales'); + }); + + it('warns and uses parent locale data when a subtag locale is not directly available', async () => { + const source = `(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`; + const files = [browserFile('polyfills.js', source)]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'fr-ZZ', translation: { greeting: translationFor('Bonjour') } }, + ]); + + const frZZ = results.get('fr-ZZ'); + expect(frZZ?.errors).toEqual([]); + expect(frZZ?.warnings).toEqual([ + "Locale data for 'fr-ZZ' cannot be found. Using locale data for 'fr'.", + ]); + const polyfills = findFile(frZZ?.outputFiles ?? [], 'polyfills.js').text; + expect(polyfills).toContain('(globalThis.$localize ??= {}).locale = "fr-ZZ";'); + expect(polyfills).toContain('ng.common.locales'); + }); + + it('warns and includes no locale data when locale data cannot be found', async () => { + const source = `(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`; + const files = [browserFile('polyfills.js', source)]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'xx-YY', translation: { greeting: translationFor('Test') } }, + ]); + + const xxYY = results.get('xx-YY'); + expect(xxYY?.errors).toEqual([]); + expect(xxYY?.warnings).toEqual([ + "Locale data for 'xx-YY' cannot be found. No locale data will be included for this locale.", + ]); + const polyfills = findFile(xxYY?.outputFiles ?? [], 'polyfills.js').text; + expect(polyfills).toContain('(globalThis.$localize ??= {}).locale = "xx-YY";'); + expect(polyfills).not.toContain('ng.common.locales'); + }); + + it('reports an error diagnostic when an invalid or unsupported locale is provided', async () => { + const source = `(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`; + const files = [browserFile('polyfills.js', source)]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'invalid locale tag', translation: { greeting: translationFor('Test') } }, + ]); + + const invalid = results.get('invalid locale tag'); + expect(invalid?.errors).toEqual([ + 'Invalid or unsupported locale provided in configuration: "invalid locale tag"', + ]); + }); + + it('injects locale data script only once when multiple ___NG_LOCALE_INSERT___ sites are present', async () => { + const source = + `(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n` + + `const secondary = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`; + const files = [browserFile('polyfills.js', source)]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + ]); + + const fr = results.get('fr'); + expect(fr?.errors).toEqual([]); + const frPolyfills = findFile(fr?.outputFiles ?? [], 'polyfills.js').text; + expect(frPolyfills).toContain('(globalThis.$localize ??= {}).locale = "fr";'); + expect(frPolyfills).toContain('const secondary = "fr";'); + + const iifeMatches = frPolyfills.match(/function\s*\(global/g); + expect(iifeMatches?.length).toBe(1); + }); + + it('preserves code and sourcemaps when a file contains the $localize keyword only in comments', async () => { + const source = '// $localize keyword in comment\nexport const message = "hello world";\n'; + const { code, map } = await transform(source, { + sourcefile: 'comment-only.ts', + loader: 'ts', + sourcemap: 'external', + }); + + const files = [browserFile('main.js', code), browserFile('main.js.map', map)]; + const localeInliner = createInliner(); + + const results = await localeInliner.inlineAll(files, [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + { locale: 'de', translation: { greeting: translationFor('Hallo') } }, + ]); + + expect(results.size).toBe(2); + + for (const locale of ['fr', 'de'] as const) { + const localeResult = results.get(locale); + expect(localeResult).toBeDefined(); + expect(localeResult?.errors).toEqual([]); + expect(localeResult?.warnings).toEqual([]); + + const mainJs = findFile(localeResult?.outputFiles ?? [], 'main.js'); + expect(mainJs.text).toBe(code); + + const mainMap = findFile(localeResult?.outputFiles ?? [], 'main.js.map'); + expect(mainMap.text).toBe(map); + } + }); + + it('correctly handles persistent caching for unmodified files', async () => { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18n-unmodified-cache-test-')); + const source = '// $localize comment only\nexport const value = 123;\n'; + + try { + const files = [browserFile('main.js', source)]; + const inliner1 = new I18nInliner({ + missingTranslation: 'error', + persistentCachePath: cacheDir, + }); + + const results1 = await inliner1.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + translationIntegrity: 'fr-1', + }, + ]); + + expect(results1.get('fr')?.errors).toEqual([]); + expect(findFile(results1.get('fr')?.outputFiles ?? [], 'main.js').text).toBe(source); + await inliner1.close(); + + const inliner2 = new I18nInliner({ + missingTranslation: 'error', + persistentCachePath: cacheDir, + }); + + const results2 = await inliner2.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + translationIntegrity: 'fr-1', + }, + ]); + + expect(results2.get('fr')?.errors).toEqual([]); + expect(findFile(results2.get('fr')?.outputFiles ?? [], 'main.js').text).toBe(source); + await inliner2.close(); + } finally { + await fs.rm(cacheDir, { recursive: true, force: true }); + } + }); + + it('caches binary translation buffer in persistent cache store when translationIntegrity is provided', async () => { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18n-inliner-trans-cache-test-')); + + try { + const files = [browserFile('main.js', GREETING_SOURCE)]; + const inliner1 = new I18nInliner({ + missingTranslation: 'error', + persistentCachePath: cacheDir, + }); + + const translationIntegrity = 'hash-test-integrity-12345'; + const results1 = await inliner1.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + translationIntegrity, + }, + ]); + + expect(results1.get('fr')?.errors).toEqual([]); + expect(findFile(results1.get('fr')?.outputFiles ?? [], 'main.js').text).toContain( + '"Bonjour"', + ); + await inliner1.close(); + + // Verify directly that the binary translation buffer was persisted on disk + const store = await createPersistentCacheStore(path.join(cacheDir, 'angular-i18n')); + try { + const translationCache = store.createCache('translations'); + const cachedData = await translationCache.get(translationIntegrity); + expect(cachedData).toBeInstanceOf(Uint8Array); + expect(cachedData?.byteLength).toBeGreaterThan(0); + } finally { + await store.close(); + } + + const inliner2 = new I18nInliner({ + missingTranslation: 'error', + persistentCachePath: cacheDir, + }); + + const results2 = await inliner2.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + translationIntegrity, + }, + ]); + + expect(results2.get('fr')?.errors).toEqual([]); + expect(findFile(results2.get('fr')?.outputFiles ?? [], 'main.js').text).toContain( + '"Bonjour"', + ); + await inliner2.close(); + } finally { + await fs.rm(cacheDir, { recursive: true, force: true }); + } + }); + + it('throws an error when duplicate locales are provided to inlineAll', async () => { + const localeInliner = createInliner(); + + await expectAsync( + localeInliner.inlineAll( + [browserFile('main.js', GREETING_SOURCE)], + [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + { locale: 'fr', translation: { greeting: translationFor('Salut') } }, + ], + ), + ).toBeRejectedWithError(/Duplicate locale provided to inliner: fr/); + }); + + it('correctly transforms files across multiple inlineAll runs on the same inliner instance', async () => { + const localeInliner = new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: 2, + }); + + try { + const files1 = [browserFile('main.js', GREETING_SOURCE)]; + // First generation + const results1 = await localeInliner.inlineAll(files1, [ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + ]); + expect(findFile(results1.get('fr')?.outputFiles ?? [], 'main.js').text).toContain( + '"Bonjour"', + ); + + // Second generation (e.g. watch mode rebuild with updated translation) + const files2 = [browserFile('main.js', GREETING_SOURCE)]; + const results2 = await localeInliner.inlineAll(files2, [ + { locale: 'fr', translation: { greeting: translationFor('Salut') } }, + { locale: 'de', translation: { greeting: translationFor('Hallo') } }, + ]); + expect(findFile(results2.get('fr')?.outputFiles ?? [], 'main.js').text).toContain('"Salut"'); + expect(findFile(results2.get('de')?.outputFiles ?? [], 'main.js').text).toContain('"Hallo"'); + } finally { + await localeInliner.close(); + } + }); + + it('correctly escapes backticks, double quotes, and expression delimiters in translated template literals', async () => { + const source = 'export const msg = $localize`:@@msg:Hello ${name}:name:!`;\n'; + const inliner = createInliner(); + + const results = await inliner.inlineAll( + [browserFile('main.js', source)], + [ + { + locale: 'fr', + translation: { + msg: parsedTranslation(['Bonjour "', '` with ${injected} and \\backslash!'], ['name']), + }, + }, + ], + ); + + const outputText = findFile(results.get('fr')?.outputFiles ?? [], 'main.js').text; + expect(outputText).toContain('`Bonjour "${name}\\` with \\${injected} and \\\\backslash!`'); + }); + + it('correctly inlines when files have varying sizes across multiple workers', async () => { + // Create a large dominant bundle (> 120 KB) and a small chunk (< 10 KB) + const largePadding = '/* padding */ console.log(1);\n'.repeat(4000); + const largeSource = `${GREETING_SOURCE}\n${largePadding}`; + const smallSource = 'console.log($localize`:@@farewell:Goodbye`);'; + + const largeFile = browserFile('main.js', largeSource); + const smallFile = browserFile('chunk.js', smallSource); + const files = [largeFile, smallFile]; + + inliner = new I18nInliner({ missingTranslation: 'error', maxConcurrency: 2 }); + + const results = await inliner.inlineAll(files, [ + { + locale: 'fr', + translation: { + greeting: translationFor('Bonjour'), + farewell: translationFor('Au revoir'), + }, + }, + { + locale: 'de', + translation: { + greeting: translationFor('Guten Tag'), + farewell: translationFor('Auf Wiedersehen'), + }, + }, + { + locale: 'es', + translation: { + greeting: translationFor('Hola'), + farewell: translationFor('Adios'), + }, + }, + ]); + + expect(results.size).toBe(3); + + const frFiles = results.get('fr')?.outputFiles ?? []; + expect(findFile(frFiles, 'main.js').text).toContain('"Bonjour"'); + expect(findFile(frFiles, 'chunk.js').text).toContain('"Au revoir"'); + + const deFiles = results.get('de')?.outputFiles ?? []; + expect(findFile(deFiles, 'main.js').text).toContain('"Guten Tag"'); + expect(findFile(deFiles, 'chunk.js').text).toContain('"Auf Wiedersehen"'); + + const esFiles = results.get('es')?.outputFiles ?? []; + expect(findFile(esFiles, 'main.js').text).toContain('"Hola"'); + expect(findFile(esFiles, 'chunk.js').text).toContain('"Adios"'); + }); + + it('respects maxConcurrency when processing multiple locales and files', async () => { + const files = [ + browserFile('main.js', GREETING_SOURCE), + browserFile('chunk.js', 'console.log($localize`:@@farewell:Goodbye`);'), + ]; + + inliner = new I18nInliner({ + missingTranslation: 'error', + maxConcurrency: 1, + }); + + const results = await inliner.inlineAll(files, [ + { + locale: 'fr', + translation: { + greeting: translationFor('Bonjour'), + farewell: translationFor('Au revoir'), + }, + }, + { + locale: 'de', + translation: { + greeting: translationFor('Guten Tag'), + farewell: translationFor('Auf Wiedersehen'), + }, + }, + ]); + + expect(results.size).toBe(2); + expect(findFile(results.get('fr')?.outputFiles ?? [], 'main.js').text).toContain('"Bonjour"'); + expect(findFile(results.get('de')?.outputFiles ?? [], 'chunk.js').text).toContain( + '"Auf Wiedersehen"', + ); + }); + + describe('maxConcurrency validation', () => { + it('throws when maxConcurrency is less than 1', () => { + expect( + () => + new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: 0, + }), + ).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + + expect( + () => + new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: -1, + }), + ).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + }); + + it('throws when maxConcurrency is not an integer', () => { + expect( + () => + new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: 1.5, + }), + ).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + + expect( + () => + new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: NaN, + }), + ).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + + expect( + () => + new I18nInliner({ + missingTranslation: 'warning', + maxConcurrency: Infinity, + }), + ).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + }); + + it('defaults to worker pool concurrency when maxConcurrency is omitted', async () => { + const maxThreadsSpy = spyOnProperty( + WorkerPool.prototype, + 'maxThreads', + 'get', + ).and.callThrough(); + + inliner = new I18nInliner({ + missingTranslation: 'warning', + }); + + const files = [browserFile('main.js', GREETING_SOURCE)]; + const results = await inliner.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + }, + ]); + + expect(results.size).toBe(1); + expect(findFile(results.get('fr')?.outputFiles ?? [], 'main.js').text).toContain('"Bonjour"'); + expect(maxThreadsSpy).toHaveBeenCalled(); + expect(maxThreadsSpy.calls.mostRecent().returnValue).toBeGreaterThanOrEqual(1); + }); + }); + + describe('supplied workerPool', () => { + let externalPool: WorkerPool | undefined; + + afterEach(async () => { + await externalPool?.destroy(); + externalPool = undefined; + }); + + it('uses the supplied workerPool to execute transformation tasks', async () => { + externalPool = new WorkerPool({ maxThreads: 1 }); + const runSpy = spyOn(externalPool, 'run').and.callThrough(); + + inliner = new I18nInliner({ missingTranslation: 'warning' }, externalPool); + + const files = [browserFile('main.js', GREETING_SOURCE)]; + const results = await inliner.inlineAll(files, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + }, + ]); + + expect(results.size).toBe(1); + expect(findFile(results.get('fr')?.outputFiles ?? [], 'main.js').text).toContain('"Bonjour"'); + expect(runSpy).toHaveBeenCalled(); + }); + + it('does not destroy the supplied workerPool on close()', async () => { + externalPool = new WorkerPool({ maxThreads: 1 }); + const destroySpy = spyOn(externalPool, 'destroy').and.callThrough(); + + inliner = new I18nInliner({ missingTranslation: 'warning' }, externalPool); + await inliner.close(); + + expect(destroySpy).not.toHaveBeenCalled(); + }); + + it('destroys internally created workerPool on close()', async () => { + const destroySpy = spyOn(WorkerPool.prototype, 'destroy').and.callThrough(); + + inliner = new I18nInliner({ missingTranslation: 'warning', maxConcurrency: 1 }); + await inliner.close(); + + expect(destroySpy).toHaveBeenCalled(); + }); + + it('supports multiple sequential inlining passes with the same supplied workerPool', async () => { + externalPool = new WorkerPool({ maxThreads: 1 }); + + inliner = new I18nInliner({ missingTranslation: 'warning' }, externalPool); + + const files1 = [browserFile('main.js', GREETING_SOURCE)]; + const results1 = await inliner.inlineAll(files1, [ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + }, + ]); + expect(findFile(results1.get('fr')?.outputFiles ?? [], 'main.js').text).toContain( + '"Bonjour"', + ); + + // Second inlining pass on the same pool + const files2 = [browserFile('main.js', GREETING_SOURCE)]; + const results2 = await inliner.inlineAll(files2, [ + { + locale: 'es', + translation: { greeting: translationFor('Hola') }, + }, + ]); + expect(findFile(results2.get('es')?.outputFiles ?? [], 'main.js').text).toContain('"Hola"'); + }); + }); +}); diff --git a/packages/angular/build/src/tools/i18n/i18n-translation-encoder.ts b/packages/angular/build/src/tools/i18n/i18n-translation-encoder.ts new file mode 100644 index 000000000000..cc8f1ae50f31 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/i18n-translation-encoder.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { ɵParsedTranslation } from '@angular/localize'; + +/** + * Magic header identifier for i18n SharedArrayBuffer translation tables ('I18N'). + */ +export const I18N_MAGIC_ID = 0x4931384e; + +/** + * Encodes a JavaScript translation dictionary into a contiguous SharedArrayBuffer. + * The buffer contains a header, a sorted index table (by key for fast O(log N) binary search), + * and a UTF-8 string pool. + * + * @param translation The translation dictionary object. + * @returns A SharedArrayBuffer containing the binary encoded translation catalog. + */ +export function encodeTranslationToBuffer( + translation: Record, +): SharedArrayBuffer { + const encoder = new TextEncoder(); + const entries = Object.entries(translation); + const entryCount = entries.length; + + // Pre-encode string keys and JSON-serialized string values + const encodedEntries: { + keyBytes: Uint8Array; + valBytes: Uint8Array; + }[] = new Array(entryCount); + + let stringPoolByteSize = 0; + + for (let i = 0; i < entryCount; i++) { + const [key, val] = entries[i]; + const keyBytes = encoder.encode(key); + const valBytes = encoder.encode(JSON.stringify(val)); + + encodedEntries[i] = { keyBytes, valBytes }; + stringPoolByteSize += keyBytes.byteLength + valBytes.byteLength; + } + + // Sort entries lexicographically by UTF-8 byte comparison. + // Lexicographical byte comparison matches the binary search reader's byte comparison, + // ensuring deterministic and zero-allocation lookups across all runtimes regardless of UTF-16 surrogate pairs. + encodedEntries.sort((a, b) => { + const aBytes = a.keyBytes; + const bBytes = b.keyBytes; + const minLen = Math.min(aBytes.length, bBytes.length); + + for (let i = 0; i < minLen; i++) { + const diff = aBytes[i] - bBytes[i]; + if (diff !== 0) { + return diff; + } + } + + return aBytes.length - bBytes.length; + }); + + const indexByteSize = entryCount * 16; + const headerByteSize = 16; + const stringPoolOffset = headerByteSize + indexByteSize; + + const totalByteSize = stringPoolOffset + stringPoolByteSize; + const buffer = new SharedArrayBuffer(totalByteSize); + + // Set up views over the SharedArrayBuffer + const uint32Header = new Uint32Array(buffer, 0, 4); + const uint32Index = new Uint32Array(buffer, headerByteSize, entryCount * 4); + const uint8Pool = new Uint8Array(buffer, stringPoolOffset); + + // Write Header: Magic, EntryCount, IndexOffset, StringPoolOffset + uint32Header[0] = I18N_MAGIC_ID; + uint32Header[1] = entryCount; + uint32Header[2] = headerByteSize; + uint32Header[3] = stringPoolOffset; + + // Write Index Table and String Pool entries + let currentPoolOffset = 0; + + for (let i = 0; i < entryCount; i++) { + const { keyBytes, valBytes } = encodedEntries[i]; + + const keyOffset = currentPoolOffset; + const keyLen = keyBytes.byteLength; + uint8Pool.set(keyBytes, keyOffset); + currentPoolOffset += keyLen; + + const valOffset = currentPoolOffset; + const valLen = valBytes.byteLength; + uint8Pool.set(valBytes, valOffset); + currentPoolOffset += valLen; + + // Index entry format (4 uint32 words / 16 bytes): + // [0]: keyOffset (uint32) + // [1]: keyLength (uint32) + // [2]: valOffset (uint32) + // [3]: valLength (uint32) + const idx = i * 4; + uint32Index[idx] = keyOffset; + uint32Index[idx + 1] = keyLen; + uint32Index[idx + 2] = valOffset; + uint32Index[idx + 3] = valLen; + } + + return buffer; +} diff --git a/packages/angular/build/src/tools/i18n/i18n-translation-encoder_spec.ts b/packages/angular/build/src/tools/i18n/i18n-translation-encoder_spec.ts new file mode 100644 index 000000000000..dd660f9a4059 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/i18n-translation-encoder_spec.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { encodeTranslationToBuffer } from './i18n-translation-encoder'; +import { + SharedTranslationDictionary, + createSharedTranslationProxy, +} from './i18n-translation-reader'; + +describe('SharedArrayBuffer Translation Encoder & Reader', () => { + it('encodes and reads simple key-value translations', () => { + const translation = { + greeting: 'Hello', + farewell: 'Goodbye', + numberKey: 42, + arrayKey: ['part1', 'part2'], + }; + + const buffer = encodeTranslationToBuffer(translation); + const dictionary = new SharedTranslationDictionary(buffer); + + expect(dictionary.get('greeting')).toEqual('Hello'); + expect(dictionary.get('farewell')).toEqual('Goodbye'); + expect(dictionary.get('numberKey')).toEqual(42); + expect(dictionary.get('arrayKey')).toEqual(['part1', 'part2']); + expect(dictionary.get('nonexistent')).toBeUndefined(); + }); + + it('works with Proxy wrapper and "in" operator', () => { + const translation = { + msg1: 'Message 1', + msg2: 'Message 2', + }; + + const buffer = encodeTranslationToBuffer(translation); + const proxy = createSharedTranslationProxy(buffer); + + expect(proxy['msg1']).toEqual('Message 1'); + expect(proxy['msg2']).toEqual('Message 2'); + expect(proxy['unknown']).toBeUndefined(); + expect('msg1' in proxy).toBeTrue(); + expect('msg2' in proxy).toBeTrue(); + expect('unknown' in proxy).toBeFalse(); + }); + + it('supports Object.prototype.hasOwnProperty and Object.getOwnPropertyDescriptor on Proxy', () => { + const translation = { + msg1: 'Message 1', + }; + + const buffer = encodeTranslationToBuffer(translation); + const proxy = createSharedTranslationProxy(buffer); + + expect(Object.prototype.hasOwnProperty.call(proxy, 'msg1')).toBeTrue(); + expect(Object.prototype.hasOwnProperty.call(proxy, 'unknown')).toBeFalse(); + + const desc = Object.getOwnPropertyDescriptor(proxy, 'msg1'); + expect(desc).toBeDefined(); + expect(desc?.value).toEqual('Message 1'); + expect(desc?.enumerable).toBeTrue(); + + const missingDesc = Object.getOwnPropertyDescriptor(proxy, 'unknown'); + expect(missingDesc).toBeUndefined(); + + // Verify standard Object.prototype methods work and don't throw + expect(typeof proxy.toString).toBe('function'); + expect(proxy.toString()).toEqual('[object Object]'); + expect(typeof proxy.valueOf).toBe('function'); + expect('toString' in proxy).toBeTrue(); + expect(String(proxy)).toEqual('[object Object]'); + }); + + it('handles non-BMP Unicode surrogate pairs and high BMP keys correctly', () => { + const translation = { + '😀': 'emoji message', + '\uE000': 'uE000 message', + 'abc': 'abc message', + }; + + const buffer = encodeTranslationToBuffer(translation); + const dictionary = new SharedTranslationDictionary(buffer); + + expect(dictionary.get('😀')).toEqual('emoji message'); + expect(dictionary.get('\uE000')).toEqual('uE000 message'); + expect(dictionary.get('abc')).toEqual('abc message'); + }); + + it('handles empty translation dictionaries', () => { + const buffer = encodeTranslationToBuffer({}); + const dictionary = new SharedTranslationDictionary(buffer); + + expect(dictionary.get('anyKey')).toBeUndefined(); + }); + + it('caches missing translation lookups efficiently', () => { + const translation = { + msg1: 'Message 1', + }; + + const buffer = encodeTranslationToBuffer(translation); + const dictionary = new SharedTranslationDictionary(buffer); + + // First lookup for missing key + expect(dictionary.get('missingKey')).toBeUndefined(); + // Second lookup for missing key (should hit lazyCache NOT_FOUND sentinel) + expect(dictionary.get('missingKey')).toBeUndefined(); + }); + + it('validates header size and offsets on initialization', () => { + // Buffer too small + const smallSab = new SharedArrayBuffer(8); + expect(() => new SharedTranslationDictionary(smallSab)).toThrowError( + 'Invalid SharedArrayBuffer translation header: buffer too small.', + ); + + // Invalid magic header + const badMagicSab = new SharedArrayBuffer(16); + expect(() => new SharedTranslationDictionary(badMagicSab)).toThrowError( + 'Invalid SharedArrayBuffer translation header magic identifier.', + ); + + // Out-of-bounds offset values + const invalidOffsetsSab = new SharedArrayBuffer(16); + const view = new Uint32Array(invalidOffsetsSab); + view[0] = 0x4931384e; // I18N_MAGIC_ID + view[1] = 100; // entryCount = 100 entries requiring 1600 bytes + view[2] = 16; + view[3] = 16; + expect(() => new SharedTranslationDictionary(invalidOffsetsSab)).toThrowError( + 'Invalid SharedArrayBuffer translation header offsets.', + ); + + // Unaligned header size + const unalignedHeaderSab = new SharedArrayBuffer(32); + const unalignedView = new Uint32Array(unalignedHeaderSab); + unalignedView[0] = 0x4931384e; + unalignedView[1] = 0; + unalignedView[2] = 17; // unaligned header byte size + unalignedView[3] = 17; + expect(() => new SharedTranslationDictionary(unalignedHeaderSab)).toThrowError( + 'Invalid SharedArrayBuffer translation header offsets.', + ); + }); + + it('safely handles corrupted index offsets within a valid header', () => { + // 1 entry: header (16 bytes) + index (16 bytes) + pool (10 bytes) = 42 bytes + const buffer = new SharedArrayBuffer(48); + const uint32 = new Uint32Array(buffer); + uint32[0] = 0x4931384e; // Magic + uint32[1] = 1; // 1 entry + uint32[2] = 16; // header size + uint32[3] = 32; // string pool offset + + // Corrupted entry: keyOffset points past pool length + uint32[4] = 100; // keyOffset = 100 + uint32[5] = 5; // keyLen = 5 + uint32[6] = 0; // valOffset = 0 + uint32[7] = 5; // valLen = 5 + + const dictionary = new SharedTranslationDictionary(buffer); + expect(dictionary.get('testKey')).toBeUndefined(); + }); +}); diff --git a/packages/angular/build/src/tools/i18n/i18n-translation-reader.ts b/packages/angular/build/src/tools/i18n/i18n-translation-reader.ts new file mode 100644 index 000000000000..c332b47f1941 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/i18n-translation-reader.ts @@ -0,0 +1,196 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { ɵParsedTranslation } from '@angular/localize'; +import { I18N_MAGIC_ID } from './i18n-translation-encoder'; + +/** + * Sentinel value used to cache non-existent message key lookups in lazyCache. + */ +const NOT_FOUND = Symbol('NOT_FOUND'); + +/** + * Compares a target key's UTF-8 byte array against a byte sequence in the string pool. + * Lexicographical byte comparison of UTF-8 sequences matches UTF-16 code-unit string comparison + * used when encoding the sorted index table. + */ +function compareBytes( + target: Uint8Array, + pool: Uint8Array, + poolOffset: number, + keyLen: number, +): number { + if (poolOffset < 0 || poolOffset + keyLen > pool.length) { + return 1; + } + + const minLen = Math.min(target.length, keyLen); + for (let i = 0; i < minLen; i++) { + const diff = target[i] - pool[poolOffset + i]; + if (diff !== 0) { + return diff; + } + } + + return target.length - keyLen; +} + +/** + * A zero-copy reader that queries translation messages directly from a SharedArrayBuffer + * using binary search over a sorted key index. + */ +export class SharedTranslationDictionary { + private readonly entryCount: number; + private readonly uint32Index: Uint32Array; + private readonly uint8Pool: Uint8Array; + private readonly decoder = new TextDecoder(); + private readonly encoder = new TextEncoder(); + private readonly lazyCache = new Map(); + + constructor(buffer: SharedArrayBuffer) { + if (buffer.byteLength < 16) { + throw new Error('Invalid SharedArrayBuffer translation header: buffer too small.'); + } + + const uint32Header = new Uint32Array(buffer, 0, 4); + if (uint32Header[0] !== I18N_MAGIC_ID) { + throw new Error('Invalid SharedArrayBuffer translation header magic identifier.'); + } + + this.entryCount = uint32Header[1]; + const headerByteSize = uint32Header[2]; + const stringPoolOffset = uint32Header[3]; + + if ( + headerByteSize < 16 || + headerByteSize % 4 !== 0 || + headerByteSize + this.entryCount * 16 > buffer.byteLength || + stringPoolOffset < headerByteSize + this.entryCount * 16 || + stringPoolOffset > buffer.byteLength + ) { + throw new Error('Invalid SharedArrayBuffer translation header offsets.'); + } + + this.uint32Index = new Uint32Array(buffer, headerByteSize, this.entryCount * 4); + this.uint8Pool = new Uint8Array(buffer, stringPoolOffset); + } + + /** + * Looks up a translation message by key. + * Performs a binary search over the index table if not previously cached in the lazy cache. + * + * @param targetKey The message key ID to search for. + * @returns The parsed translation message, or undefined if not found. + */ + get(targetKey: string): T | undefined { + const cached = this.lazyCache.get(targetKey); + if (cached !== undefined) { + return cached === NOT_FOUND ? undefined : cached; + } + + const targetBytes = this.encoder.encode(targetKey); + let low = 0; + let high = this.entryCount - 1; + + while (low <= high) { + const mid = (low + high) >>> 1; + const idx = mid * 4; + + const keyOffset = this.uint32Index[idx]; + const keyLen = this.uint32Index[idx + 1]; + + const cmp = compareBytes(targetBytes, this.uint8Pool, keyOffset, keyLen); + if (cmp === 0) { + const valOffset = this.uint32Index[idx + 2]; + const valLen = this.uint32Index[idx + 3]; + if (valOffset < 0 || valOffset + valLen > this.uint8Pool.length) { + this.lazyCache.set(targetKey, NOT_FOUND); + + return undefined; + } + + const valBytes = this.uint8Pool.subarray(valOffset, valOffset + valLen); + const valJson = this.decoder.decode(valBytes); + + try { + const val = JSON.parse(valJson) as T; + this.lazyCache.set(targetKey, val); + + return val; + } catch { + this.lazyCache.set(targetKey, NOT_FOUND); + + return undefined; + } + } else if (cmp < 0) { + high = mid - 1; + } else { + low = mid + 1; + } + } + + this.lazyCache.set(targetKey, NOT_FOUND); + + return undefined; + } +} + +/** + * Creates a JavaScript object Proxy wrapping a SharedTranslationDictionary so it can be passed + * directly to `@angular/localize` inliner functions as a standard translation Record. + * + * Traps `get`, `has`, and `getOwnPropertyDescriptor` to fulfill all `@angular/localize` `translate()` + * lookup requirements (`translations[id]`, `translations[legacyId]`, `Object.hasOwn(translations, id)`). + * The `ownKeys` trap is intentionally omitted so keys are not eagerly decoded upfront upon reflection. + * + * @param buffer The SharedArrayBuffer containing binary encoded translation catalog. + * @returns A Proxy object that intercepts property reads and queries the SharedTranslationDictionary. + */ +export function createSharedTranslationProxy( + buffer: SharedArrayBuffer, +): Record { + const dictionary = new SharedTranslationDictionary(buffer); + + return new Proxy( + {}, + { + get(target, prop: string | symbol) { + if (typeof prop === 'string') { + const value = dictionary.get(prop); + if (value !== undefined) { + return value; + } + } + + return Reflect.get(target, prop); + }, + has(target, prop: string | symbol) { + if (typeof prop === 'string' && dictionary.get(prop) !== undefined) { + return true; + } + + return Reflect.has(target, prop); + }, + getOwnPropertyDescriptor(target, prop: string | symbol) { + if (typeof prop === 'string') { + const val = dictionary.get(prop); + if (val !== undefined) { + return { + enumerable: true, + configurable: true, + writable: false, + value: val, + }; + } + } + + return Reflect.getOwnPropertyDescriptor(target, prop); + }, + }, + ); +} diff --git a/packages/angular/build/src/tools/i18n/index.ts b/packages/angular/build/src/tools/i18n/index.ts new file mode 100644 index 000000000000..a2f8cdd2d8f3 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/index.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './i18n-inliner'; +export * from './i18n-translation-encoder'; +export * from './i18n-translation-reader'; +export * from './locale-data'; diff --git a/packages/angular/build/src/tools/i18n/locale-data.ts b/packages/angular/build/src/tools/i18n/locale-data.ts new file mode 100644 index 000000000000..0418830b69a8 --- /dev/null +++ b/packages/angular/build/src/tools/i18n/locale-data.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readFile } from 'node:fs/promises'; +import { createProjectResolver } from '../../utils/resolve-project'; + +/** + * The internal namespace used by generated locale import statements and Angular locale data plugin. + */ +export const LOCALE_DATA_NAMESPACE = 'angular:locale/data'; + +/** + * The base module location used to search for locale specific data. + */ +export const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global'; + +/** + * Result of resolving locale data for a given locale tag. + */ +export interface LocaleDataResolution { + path?: string; + warning?: string; + error?: string; +} + +/** + * Result of loading locale data for a given locale tag. + */ +export interface LoadedLocaleData { + code?: string; + warning?: string; + error?: string; +} + +const localeDataCache = new Map>(); + +/** + * Resolves the path to the Angular locale data file for a given locale tag. + * + * @param rawLocaleTag The raw locale identifier (e.g. "fr-CA", "de", "en-US"). + * @param projectResolve A function that attempts to resolve a path string to an absolute file path. + * @returns Resolution result with file path, or warning/error diagnostics if applicable. + */ +export function resolveLocaleDataPath( + rawLocaleTag: string, + projectResolve: (potentialPath: string) => string | undefined, +): LocaleDataResolution { + let partialLocaleTag: string; + try { + const locale = new Intl.Locale(rawLocaleTag); + partialLocaleTag = locale.baseName; + } catch { + return { + error: `Invalid or unsupported locale provided in configuration: "${rawLocaleTag}"`, + }; + } + + let exact = true; + while (partialLocaleTag) { + // Angular embeds the `en`/`en-US` locale into the framework and it does not need to be included again here. + if (partialLocaleTag === 'en' || partialLocaleTag === 'en-US') { + return {}; + } + + const potentialPath = `${LOCALE_DATA_BASE_MODULE}/${partialLocaleTag}`; + try { + const resolvedPath = projectResolve(potentialPath); + if (resolvedPath) { + return { + path: resolvedPath, + warning: exact + ? undefined + : `Locale data for '${rawLocaleTag}' cannot be found. Using locale data for '${partialLocaleTag}'.`, + }; + } + } catch {} + + // Remove the last subtag and try again with a less specific locale. + const parts = partialLocaleTag.split('-'); + partialLocaleTag = parts.slice(0, -1).join('-'); + exact = false; + } + + return { + warning: `Locale data for '${rawLocaleTag}' cannot be found. No locale data will be included for this locale.`, + }; +} + +/** + * Loads the Angular global locale data script for a specified locale tag. + * + * @param rawLocaleTag The raw locale identifier (e.g. "fr-CA", "de", "en-US"). + * @param projectRoot Optional project root for module resolution. + * @returns A promise resolving to the loaded locale data script code and any diagnostic warnings. + */ +export function loadLocaleData( + rawLocaleTag: string, + projectRoot?: string, +): Promise { + let cached = localeDataCache.get(rawLocaleTag); + if (!cached) { + cached = (async () => { + const projectResolve = createProjectResolver(projectRoot ?? process.cwd()); + const resolution = resolveLocaleDataPath(rawLocaleTag, (potentialPath) => { + try { + return projectResolve(potentialPath); + } catch { + return undefined; + } + }); + + if (resolution.error) { + return { error: resolution.error }; + } + + if (resolution.path) { + try { + const code = await readFile(resolution.path, 'utf8'); + + return { code, warning: resolution.warning }; + } catch (e) { + return { error: `Failed to read locale data file: ${(e as Error).message}` }; + } + } + + return { warning: resolution.warning }; + })(); + + localeDataCache.set(rawLocaleTag, cached); + } + + return cached; +} diff --git a/packages/angular/build/src/tools/javascript-transformer/index.ts b/packages/angular/build/src/tools/javascript-transformer/index.ts new file mode 100644 index 000000000000..399eea87e644 --- /dev/null +++ b/packages/angular/build/src/tools/javascript-transformer/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './javascript-transformer'; diff --git a/packages/angular/build/src/tools/javascript-transformer/javascript-transformer-worker.ts b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer-worker.ts new file mode 100644 index 000000000000..87df19ee81eb --- /dev/null +++ b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer-worker.ts @@ -0,0 +1,270 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { DecodedSourceMap, EncodedSourceMap } from '@ampproject/remapping'; +import type { PluginItem } from '@babel/core'; +import { createRequire } from 'node:module'; +import { workerData } from 'node:worker_threads'; +import Piscina from 'piscina'; +import { useBabelLinker } from '../../utils/environment-options.js'; +import { + findTrailingSourceMapComment, + isTrailingSourceMapComment, + loadInputSourceMap, + loadInputSourceMapFromUrl, + removeSourceMappingURL, +} from '../../utils/source-map'; +import { transform as transformWithOxc } from '../oxc/oxc-transform.js'; +import type { JavaScriptTransformerOptions } from './javascript-transformer'; + +interface JavaScriptTransformRequest { + filename: string; + data: string | Uint8Array; + skipLinker?: boolean; + sideEffects?: boolean; + instrumentForCoverage?: boolean; +} + +interface TransformOptions extends Omit { + inputSourceMap?: EncodedSourceMap; + isAlreadyStripped?: boolean; +} + +const { + sourcemap = false, + thirdPartySourcemaps = false, + advancedOptimizations = false, + jit = false, +} = (workerData || {}) as Partial; + +let babelLinkerDeps: + | Promise< + [ + typeof import('@angular/compiler-cli/linker/babel'), + typeof import('@angular/compiler-cli'), + typeof import('@babel/core'), + ] + > + | undefined; + +let remapping: typeof import('@ampproject/remapping').default | undefined; + +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); + +async function instrumentCoverage( + filename: string, + data: string, + useInputSourcemap: boolean, +): Promise<{ code: string; map?: EncodedSourceMap }> { + try { + let resolvedPath = 'istanbul-lib-instrument'; + try { + const requireFn = createRequire(filename); + resolvedPath = requireFn.resolve('istanbul-lib-instrument'); + } catch { + // Fallback to pool worker import traversal + } + + const { createInstrumenter } = (await import( + resolvedPath + )) as typeof import('istanbul-lib-instrument'); + const instrumenter = createInstrumenter({ + produceSourceMap: useInputSourcemap, + esModules: true, + }); + + const inputSourceMap = useInputSourcemap ? loadInputSourceMap(filename, data) : undefined; + const instrumentedCode = instrumenter.instrumentSync( + data, + filename, + inputSourceMap as Parameters[2], + ); + const lastMap = useInputSourcemap + ? (instrumenter.lastSourceMap() as EncodedSourceMap) + : undefined; + + return { + code: instrumentedCode, + map: lastMap ?? undefined, + }; + } catch (error) { + throw new Error( + `The 'istanbul-lib-instrument' package is required for code coverage but was not found. Please install the package.`, + { cause: error }, + ); + } +} + +export default async function transformJavaScript( + request: JavaScriptTransformRequest, +): Promise { + const { filename, data, ...options } = request; + + const useInputSourcemap = + sourcemap && (!!thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename)); + + let textData: string; + let inputSourceMap: EncodedSourceMap | undefined; + let isAlreadyStripped = false; + + if (typeof data !== 'string') { + const trailing = findTrailingSourceMapComment(data); + if (trailing === null) { + // 0 comments: fast path, no sourcemap to load or strip + textData = textDecoder.decode(data); + isAlreadyStripped = true; + } else if (trailing !== undefined) { + if (useInputSourcemap) { + inputSourceMap = loadInputSourceMapFromUrl(filename, trailing.urlLine); + if (inputSourceMap !== undefined) { + // Valid trailing sourcemap comment confirmed: safe to slice code buffer for transformation passes. + // Note: If no passes modify the code, the untouched original `data` buffer is returned below. + textData = textDecoder.decode(trailing.code); + isAlreadyStripped = true; + } else { + // Not a valid trailing sourcemap (e.g. inside template literal): fallback to full decode + textData = textDecoder.decode(data); + } + } else if (isTrailingSourceMapComment(trailing.urlLine)) { + // Valid trailing sourcemap comment confirmed: safe to slice code buffer + textData = textDecoder.decode(trailing.code); + isAlreadyStripped = true; + } else { + // Fallback to full decode and state-machine stripping + textData = textDecoder.decode(data); + } + } else { + // Multiple comments or comment not at line start: fall back to full decode and string parser + textData = textDecoder.decode(data); + } + } else { + textData = data; + } + + const transformedData = await transformJavaScriptImpl(filename, textData, { + ...options, + inputSourceMap, + isAlreadyStripped, + }); + + // If no transformations modified the code, return the original untouched data buffer via `move`. + // This preserves any original trailing sourcemap comment and avoids re-encoding. + if (transformedData === textData && typeof data !== 'string') { + return Piscina.move(data); + } + + return Piscina.move(textEncoder.encode(transformedData)); +} + +async function transformJavaScriptImpl( + filename: string, + data: string, + options: TransformOptions, +): Promise { + const shouldLink = !options.skipLinker; + const useInputSourcemap = + sourcemap && (!!thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename)); + + let code = data; + const maps: (DecodedSourceMap | EncodedSourceMap)[] = []; + let coverageMap: EncodedSourceMap | undefined; + + if (options.instrumentForCoverage) { + const result = await instrumentCoverage(filename, code, useInputSourcemap); + code = result.code; + coverageMap = result.map; + } + + if (shouldLink && useBabelLinker) { + babelLinkerDeps ??= Promise.all([ + import('@angular/compiler-cli/linker/babel'), + import('@angular/compiler-cli'), + import('@babel/core'), + ]); + const [{ createEs2015LinkerPlugin }, { ConsoleLogger, LogLevel }, { transformAsync }] = + await babelLinkerDeps; + + const result = await transformAsync(code, { + filename, + inputSourceMap: false, + sourceMaps: !!useInputSourcemap, + compact: false, + configFile: false, + babelrc: false, + browserslistConfigFile: false, + plugins: [ + createEs2015LinkerPlugin({ + fileSystem: { + exists: () => false, + readFile: () => '', + resolve: (...paths: string[]) => paths.join('/'), + dirname: (path: string) => path.split('/').slice(0, -1).join('/'), + relative: (_from: string, to: string) => to, + } as never, + logger: new ConsoleLogger(LogLevel.info), + linkerJitMode: jit, + // This is a workaround until https://github.com/angular/angular/issues/42769 is fixed. + sourceMapping: false, + }) as PluginItem, + ], + }); + + code = result?.code ?? code; + if (result?.map) { + maps.push(result.map as EncodedSourceMap); + } + } + + // Run Oxc linking and/or advanced optimizations in a single unified AST traversal pass + const oxcLink = shouldLink && !useBabelLinker; + if (oxcLink || advancedOptimizations) { + const sideEffectFree = options.sideEffects === false; + const safeAngularPackage = + sideEffectFree && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename); + const topLevelSafeMode = !safeAngularPackage; + + const result = transformWithOxc(filename, code, { + link: oxcLink, + jit, + advancedOptimizations, + sourcemap: useInputSourcemap, + sideEffects: options.sideEffects, + topLevelSafeMode, + }); + code = result.code; + if (result.map) { + maps.push(result.map); + } + } + + if (useInputSourcemap) { + const baseMap = coverageMap ?? options.inputSourceMap ?? loadInputSourceMap(filename, data); + if (maps.length > 0 || coverageMap) { + if (!options.isAlreadyStripped) { + code = removeSourceMappingURL(code); + } + const remappingChain: (DecodedSourceMap | EncodedSourceMap)[] = maps.reverse(); + if (baseMap) { + remappingChain.push(baseMap); + } + + if (remappingChain.length > 0) { + remapping ??= (await import('@ampproject/remapping')).default; + const finalMap = remapping(remappingChain, () => null).toString(); + const base64Map = Buffer.from(finalMap).toString('base64'); + code += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64Map}`; + } + } + + return code; + } + + // Strip sourcemaps if they should not be used + return options.isAlreadyStripped ? code : removeSourceMappingURL(code); +} diff --git a/packages/angular/build/src/tools/javascript-transformer/javascript-transformer.ts b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer.ts new file mode 100644 index 000000000000..56a7b4eb6ed9 --- /dev/null +++ b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer.ts @@ -0,0 +1,393 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readFile } from 'node:fs/promises'; +import { Cache } from '../../utils/cache'; +import { createContentHash } from '../../utils/hash'; +import { IMPORT_EXEC_ARGV } from '../../utils/server-rendering/esm-in-memory-loader/utils'; +import { removeSourceMappingURL } from '../../utils/source-map'; +import { WorkerPool, WorkerPoolOptions } from '../../utils/worker-pool'; + +const LINKER_DECLARATION_PREFIX = 'ɵɵngDeclare'; +const LINKER_DECLARATION_PREFIX_BYTES = Buffer.from(LINKER_DECLARATION_PREFIX, 'utf-8'); + +const ADVANCED_OPTIMIZATION_TOKENS = [ + 'ɵ', + 'InjectionToken', + 'INJECTOR_KEY', + 'ctorParameters', + 'decorators', + 'propDecorators', +] as const; + +const ADVANCED_OPTIMIZATION_TOKEN_BYTES = ADVANCED_OPTIMIZATION_TOKENS.map((token) => + Buffer.from(token, 'utf-8'), +); + +const DECORATOR_TOKENS = ['__decorate', '__esDecorate'] as const; +const DECORATOR_TOKEN_BYTES = DECORATOR_TOKENS.map((token) => Buffer.from(token, 'utf-8')); + +const ADVANCED_OPTIMIZATION_REGEX = new RegExp(ADVANCED_OPTIMIZATION_TOKENS.join('|')); +const DECORATOR_REGEX = new RegExp(DECORATOR_TOKENS.join('|')); + +/** + * Determines whether JavaScript code contains potential candidate constructs for advanced optimizations. + * When false, advanced optimizations can be bypassed without worker dispatch or AST parsing. + * + * @param filename The full path to the file. + * @param data The data (string or Buffer) of the file. + * @param sideEffects An optional lazy resolver callback that returns whether the file is considered side-effect free. + * @returns True if the code may contain constructs that advanced optimizations can mutate. + */ +async function hasAdvancedOptimizationCandidates( + filename: string, + data: string | Uint8Array, + sideEffects?: () => Promise, +): Promise { + // Side-effect-free @angular/ packages undergo top-level pure function annotations + if (/[\\/]node_modules[\\/]@angular[\\/]/.test(filename) && (await sideEffects?.()) === false) { + return true; + } + + if (typeof data === 'string') { + const hasDecorators = DECORATOR_REGEX.test(data); + if (hasDecorators && (await sideEffects?.()) === false) { + return true; + } + + return ADVANCED_OPTIMIZATION_REGEX.test(data); + } + + const dataBuffer = Buffer.isBuffer(data) + ? data + : Buffer.from(data.buffer, data.byteOffset, data.byteLength); + + for (const tokenBytes of DECORATOR_TOKEN_BYTES) { + if (dataBuffer.includes(tokenBytes)) { + if ((await sideEffects?.()) === false) { + return true; + } + break; + } + } + + for (const tokenBytes of ADVANCED_OPTIMIZATION_TOKEN_BYTES) { + if (dataBuffer.includes(tokenBytes)) { + return true; + } + } + + return false; +} + +/** + * Determines whether JavaScript code requires Angular linker processing. + * + * @param path The full path to the file. + * @param data The data (string or Buffer) of the file. + * @returns True if the code contains an Angular partial declaration; otherwise false. + */ +function requiresLinking(path: string, data: string | Uint8Array): boolean { + // @angular/core and @angular/compiler will cause false positives + // Also, TypeScript files do not require linking + if (/[\\/]@angular[\\/](?:compiler|core)[\\/]|\.[cm]?tsx?$/.test(path)) { + return false; + } + + if (typeof data === 'string') { + return data.includes(LINKER_DECLARATION_PREFIX); + } + + const dataBuffer = Buffer.isBuffer(data) + ? data + : Buffer.from(data.buffer, data.byteOffset, data.byteLength); + + return dataBuffer.includes(LINKER_DECLARATION_PREFIX_BYTES); +} + +/** + * Transformation options that should apply to all transformed files and data. + */ +export interface JavaScriptTransformerOptions { + sourcemap: boolean; + thirdPartySourcemaps?: boolean; + advancedOptimizations?: boolean; + jit?: boolean; + + /** + * The maximum number of concurrent transformation operations. + * When omitted, concurrency defaults to the available worker pool threads. + */ + maxConcurrency?: number; +} + +/** + * Transformation options for an individual file or data transform request. + */ +export interface TransformOptions { + /** If true, bypass all Angular linker processing; if false, attempt linking. */ + skipLinker?: boolean; + + /** + * An optional lazy resolver callback that returns whether the file has side-effects. + * If it resolves to false, top-level pure function annotations and decorator wrapping may be applied. + */ + sideEffects?: () => Promise; + + /** If true, instrument the code for test coverage. */ + instrumentForCoverage?: boolean; +} + +/** + * A class that performs transformation of JavaScript files and raw data. + * A worker pool is used to distribute the transformation actions and allow + * parallel processing. Transformation behavior is based on the filename and + * data. Transformations may include: async downleveling, Angular linking, + * and advanced optimizations. + */ +export class JavaScriptTransformer { + #workerPool: WorkerPool | undefined; + #commonOptions: Required>; + #fileCacheKeyBase: Uint8Array; + + /** Queue of pending transformation tasks waiting for an active concurrency slot. */ + #pendingTasks: { resolve: () => void; reject: (reason: Error) => void }[] = []; + + /** Current count of actively executing transformation tasks. */ + #activeTasks = 0; + + get #maxConcurrency(): number { + return this.options.maxConcurrency ?? (this.#workerPool?.maxThreads || 1); + } + + constructor( + private readonly options: JavaScriptTransformerOptions, + private readonly cache?: Cache, + ) { + if ( + options.maxConcurrency !== undefined && + (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency < 1) + ) { + throw new RangeError('options.maxConcurrency must be an integer greater than or equal to 1.'); + } + + // Extract options to ensure only the named options are serialized and sent to the worker + const { + sourcemap, + thirdPartySourcemaps = false, + advancedOptimizations = false, + jit = false, + } = options; + this.#commonOptions = { + sourcemap, + thirdPartySourcemaps, + advancedOptimizations, + jit, + }; + this.#fileCacheKeyBase = Buffer.from(JSON.stringify(this.#commonOptions), 'utf-8'); + this.#workerPool = this.#ensureWorkerPool(); + } + + /** + * Executes a transformation action using a semaphore-based backpressure throttle. + * Prevents libuv thread pool saturation and excessive V8 heap accumulation. + * @param action A callback that produces a promise for the transformation result. + * @returns A promise resolving to the transformation result. + */ + async #runWithThrottle(action: () => Promise): Promise { + if (this.#activeTasks >= this.#maxConcurrency) { + await new Promise((resolve, reject) => { + this.#pendingTasks.push({ resolve, reject }); + }); + } else { + this.#activeTasks++; + } + + try { + return await action(); + } finally { + const next = this.#pendingTasks.shift(); + if (next) { + next.resolve(); + } else { + this.#activeTasks--; + } + } + } + + #ensureWorkerPool(): WorkerPool { + if (this.#workerPool) { + return this.#workerPool; + } + + const workerPoolOptions: WorkerPoolOptions = { + filename: require.resolve('./javascript-transformer-worker'), + workerData: this.#commonOptions, + ...(this.options.maxConcurrency !== undefined && { + minThreads: this.options.maxConcurrency, + maxThreads: this.options.maxConcurrency, + }), + }; + + // Prevent passing SSR `--import` (loader-hooks) from parent to child worker. + const filteredExecArgv = process.execArgv.filter((v) => v !== IMPORT_EXEC_ARGV); + if (process.execArgv.length !== filteredExecArgv.length) { + workerPoolOptions.execArgv = filteredExecArgv; + } + + this.#workerPool = new WorkerPool(workerPoolOptions); + + return this.#workerPool; + } + + /** + * Performs JavaScript transformations on a file from the filesystem. + * If no transformations are required, the data for the original file will be returned. + * @param filename The full path to the file. + * @param options Transformation options specific to this file. + * @returns A promise that resolves to a UTF-8 encoded Uint8Array containing the result. + */ + async transformFile(filename: string, options?: TransformOptions): Promise { + return this.#runWithThrottle(async () => { + const data = await readFile(filename); + + return this.#transform(filename, data, options); + }); + } + + /** + * Performs JavaScript transformations on the provided data of a file. The file does not need + * to exist on the filesystem. + * @param filename The full path of the file represented by the data. + * @param data The data of the file that should be transformed. + * @param options Transformation options specific to this file data. + * @returns A promise that resolves to a UTF-8 encoded Uint8Array containing the result. + */ + async transformData( + filename: string, + data: string | Uint8Array, + options?: TransformOptions, + ): Promise { + return this.#runWithThrottle(() => this.#transform(filename, data, options)); + } + + async #transform( + filename: string, + data: string | Uint8Array, + options?: TransformOptions, + ): Promise { + let resolvedSideEffects: boolean | undefined; + let sideEffectsQueried = false; + + const sideEffectsGetter = options?.sideEffects + ? async () => { + if (!sideEffectsQueried) { + sideEffectsQueried = true; + resolvedSideEffects = await options.sideEffects?.(); + } + + return resolvedSideEffects; + } + : undefined; + + const shouldLink = !options?.skipLinker && requiresLinking(filename, data); + const shouldOptimize = + this.#commonOptions.advancedOptimizations && + (await hasAdvancedOptimizationCandidates(filename, data, sideEffectsGetter)); + + // Perform a quick test to determine if the data needs any transformations. + // This allows directly returning the data without the worker communication overhead. + if (!shouldLink && !shouldOptimize && !options?.instrumentForCoverage) { + const keepSourcemap = + this.#commonOptions.sourcemap && + (!!this.#commonOptions.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename)); + + if (typeof data === 'string') { + return Buffer.from(keepSourcemap ? data : removeSourceMappingURL(data), 'utf-8'); + } + + return keepSourcemap ? data : removeSourceMappingURL(data); + } + + let cacheKey: string | undefined; + if (this.cache) { + // Create a cache key from the file data and options that affect the output. + // NOTE: If additional options are added, this may need to be updated. + const hasher = createContentHash(); + hasher.update( + `${!options?.skipLinker}--${resolvedSideEffects === false}--${!!options?.instrumentForCoverage}`, + ); + hasher.update(data); + hasher.update(this.#fileCacheKeyBase); + cacheKey = hasher.digest(); + + try { + const cached = await this.cache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + } catch { + // Failure to get the value should not fail the transform + } + } + + // Only standalone (non-pooled) ArrayBuffers can be transferred across worker threads. + // Node.js shares an internal 8KB ArrayBuffer pool for small buffers, and transferring + // a pooled buffer will throw a DataCloneError because detaching it invalidates other slices. + // In addition, SharedArrayBuffers cannot be transferred, and Yarn PnP has deserialization issues. + const isTransferable = + typeof data !== 'string' && + data.buffer instanceof ArrayBuffer && + data.byteOffset === 0 && + data.byteLength === data.buffer.byteLength && + !process.versions.pnp; + + const result = (await this.#ensureWorkerPool().run( + { + filename, + data, + skipLinker: !shouldLink, + sideEffects: resolvedSideEffects, + instrumentForCoverage: options?.instrumentForCoverage, + }, + { + transferList: isTransferable ? [data.buffer] : undefined, + }, + )) as Uint8Array; + + if (this.cache && cacheKey) { + try { + await this.cache.put(cacheKey, result); + } catch { + // Failure to store the value in the cache should not fail the transform + } + } + + return result; + } + + /** + * Stops all active transformation tasks and shuts down all workers. + * @returns A void promise that resolves when closing is complete. + */ + async close(): Promise { + const pending = this.#pendingTasks; + this.#pendingTasks = []; + for (const task of pending) { + task.reject(new Error('JavaScriptTransformer closed.')); + } + + if (this.#workerPool) { + try { + await this.#workerPool.destroy(); + } finally { + this.#workerPool = undefined; + } + } + } +} diff --git a/packages/angular/build/src/tools/javascript-transformer/javascript-transformer_spec.ts b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer_spec.ts new file mode 100644 index 000000000000..94d671ef2a72 --- /dev/null +++ b/packages/angular/build/src/tools/javascript-transformer/javascript-transformer_spec.ts @@ -0,0 +1,662 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { JavaScriptTransformer } from './javascript-transformer'; + +describe('JavaScriptTransformer sourcemaps', () => { + let transformer: JavaScriptTransformer; + + afterEach(async () => { + await transformer?.close(); + }); + + function extractSourcemap(code: string): Record | null { + const match = code.match( + /\/\/# sourceMappingURL=data:application\/json;charset=utf-8;base64,(.+)/, + ); + if (!match) { + return null; + } + + return JSON.parse(Buffer.from(match[1], 'base64').toString('utf-8')); + } + + it('should remap correctly when only advanced optimizations are applied', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputMap = { + version: 3, + sources: ['src/app.ts'], + sourcesContent: ['export class MyClass { static ɵprov = 42; }'], + mappings: 'AAAA', + names: [], + }; + const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); + const input = `export class MyClass { static ɵprov = 42; }\n//# sourceMappingURL=data:application/json;base64,${base64Map}`; + + const result = await transformer.transformData('src/app.js', input, { skipLinker: true }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('src/app.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should remap correctly when only linking is applied', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + thirdPartySourcemaps: true, + maxConcurrency: 1, + }); + + const inputMap = { + version: 3, + sources: ['node_modules/my-lib/directive.ts'], + sourcesContent: ['export class MyDirective {}'], + mappings: 'AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA', + names: [], + }; + const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); + const input = ` + import * as i0 from "@angular/core"; + export class MyDirective {} + MyDirective.ɵdir = i0.ɵɵngDeclareDirective({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyDirective, + selector: "[my-dir]" + }); + //# sourceMappingURL=data:application/json;base64,${base64Map} + `; + + const result = await transformer.transformData('node_modules/my-lib/directive.js', input, { + skipLinker: false, + }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('node_modules/my-lib/directive.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should defer and chain remapping when both linking and advanced optimizations are applied', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + thirdPartySourcemaps: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputMap = { + version: 3, + sources: ['node_modules/my-lib/component.ts'], + sourcesContent: ['export class MyComponent {}'], + mappings: 'AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA', + names: [], + }; + const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); + const input = ` + import * as i0 from "@angular/core"; + export class MyComponent {} + MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyComponent, + selector: "my-cmp", + template: "
" + }); + //# sourceMappingURL=data:application/json;base64,${base64Map} + `; + + const result = await transformer.transformData('node_modules/my-lib/component.js', input, { + skipLinker: false, + }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('node_modules/my-lib/component.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should produce a valid sourcemap when no input sourcemap is present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const input = 'export class MyClass { static ɵprov = 42; }'; + const result = await transformer.transformData('src/app.js', input, { skipLinker: true }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('src/app.js'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should remap correctly when coverage instrumentation is applied with an input sourcemap', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + maxConcurrency: 1, + }); + + const inputMap = { + version: 3, + sources: ['src/counter.ts'], + sourcesContent: ['export function add(a: number, b: number) { return a + b; }'], + mappings: 'AAAA', + names: [], + }; + const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); + const input = `export function add(a, b) { return a + b; }\n//# sourceMappingURL=data:application/json;base64,${base64Map}`; + + const result = await transformer.transformData('src/counter.js', input, { + skipLinker: true, + instrumentForCoverage: true, + }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('src/counter.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should defer and chain remapping when coverage instrumentation and advanced optimizations are applied', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputMap = { + version: 3, + sources: ['src/app.ts'], + sourcesContent: ['const x = new SomeClass();'], + mappings: 'AAAA', + names: [], + }; + const base64Map = Buffer.from(JSON.stringify(inputMap)).toString('base64'); + const input = `var x = new SomeClass();\n//# sourceMappingURL=data:application/json;base64,${base64Map}`; + + const result = await transformer.transformData('src/app.js', input, { + skipLinker: true, + instrumentForCoverage: true, + }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('src/app.ts'); + expect(typeof map?.['mappings']).toBe('string'); + expect((map?.['mappings'] as string).length).toBeGreaterThan(0); + }); + + it('should accept a Uint8Array input in transformData', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('export class MyClass { static ɵprov = 42; }', 'utf-8'); + const result = await transformer.transformData('src/app.js', inputBuffer, { skipLinker: true }); + const text = Buffer.from(result).toString('utf-8'); + const map = extractSourcemap(text); + + expect(map).toBeDefined(); + expect(map?.['version']).toBe(3); + expect(map?.['sources']).toContain('src/app.js'); + expect(typeof map?.['mappings']).toBe('string'); + }); + + it('should strip trailing sourcemap comments from Uint8Array input on fast-path', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from( + 'console.log("hello");\n//# sourceMappingURL=app.js.map', + 'utf-8', + ); + const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, { + skipLinker: true, + }); + const text = Buffer.from(result).toString('utf-8'); + + expect(text).toBe('console.log("hello");\n'); + }); + + it('should return Uint8Array input untouched on fast-path when no sourcemap comment is present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('console.log("hello");\nconst x = 1;', 'utf-8'); + const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, { + skipLinker: true, + }); + + expect(result).toBe(inputBuffer); + }); + + it('should return Uint8Array untouched when skipLinker is false but file contains no linker declarations', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('console.log("no linking required");\nconst x = 1;', 'utf-8'); + const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, { + skipLinker: false, + }); + + expect(result).toBe(inputBuffer); + }); + + it('should bypass worker and skip linking for @angular/core and @angular/compiler paths', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('export const ɵɵngDeclareDirective = () => {};', 'utf-8'); + const result = await transformer.transformData( + 'node_modules/@angular/core/fesm2022/core.mjs', + inputBuffer, + { skipLinker: false }, + ); + + expect(result).toBe(inputBuffer); + }); + + it('should bypass worker and skip linking for TypeScript file extensions (.ts, .tsx, .mts, .cts)', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('export const ɵɵngDeclareDirective = () => {};', 'utf-8'); + + for (const ext of ['.ts', '.tsx', '.mts', '.cts']) { + const result = await transformer.transformData(`src/app/directive${ext}`, inputBuffer, { + skipLinker: false, + }); + + expect(result).toBe(inputBuffer); + } + }); + + it('should not exclude packages with similar prefixes such as @angular/compiler-cli', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); + + const input = ` + import * as i0 from "@angular/core"; + export class MyDirective {} + MyDirective.ɵdir = i0.ɵɵngDeclareDirective({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyDirective, + selector: "[my-dir]" + }); + `; + + const result = await transformer.transformData( + 'node_modules/@angular/compiler-cli/test.js', + input, + { skipLinker: false }, + ); + const text = Buffer.from(result).toString('utf-8'); + + expect(text).not.toContain('i0.ɵɵngDeclareDirective'); + }); + + describe('advanced optimizations fast-path pre-filter', () => { + it('should bypass worker and return input buffer directly when no candidate tokens are present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from( + 'function add(a, b) { return a + b; }\nconst result = add(1, 2);', + 'utf-8', + ); + const result = await transformer.transformData('src/math.js', inputBuffer, { + skipLinker: true, + }); + + expect(result).toBe(inputBuffer); + }); + + it('should bypass worker for standard classes without static properties', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from( + `export class UserService { + constructor(http) { this.http = http; } + getUser(id) { return this.http.get('/users/' + id); } + }`, + 'utf-8', + ); + const result = await transformer.transformData('src/user.service.js', inputBuffer, { + skipLinker: true, + }); + + expect(result).toBe(inputBuffer); + }); + + it('should bypass worker for default exports without static properties or Angular metadata', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from( + `export default class UserService { + constructor(http) { this.http = http; } + getUser(id) { return this.http.get('/users/' + id); } + }`, + 'utf-8', + ); + const result = await transformer.transformData('src/user.service.js', inputBuffer, { + skipLinker: true, + }); + + expect(result).toBe(inputBuffer); + }); + + it('should bypass worker for classes with static members when no Angular metadata is present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('export class MyComponent { static prop = 42; }', 'utf-8'); + const result = await transformer.transformData('src/component.js', inputBuffer, { + skipLinker: true, + }); + + expect(result).toBe(inputBuffer); + }); + + it('should dispatch to worker when Angular tokens are present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const input = 'export class MyService { static ɵprov = true; }'; + const result = await transformer.transformData('src/service.js', input, { skipLinker: true }); + const text = Buffer.from(result).toString('utf-8'); + + expect(text).toContain('let MyService = /*#__PURE__*/ (() => {'); + }); + + it('should dispatch to worker when decorator tokens are present and sideEffects is false', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputBuffer = Buffer.from('const MyClass = __decorate([], class {});', 'utf-8'); + const result = await transformer.transformData('src/class.js', inputBuffer, { + skipLinker: true, + sideEffects: async () => false, + }); + + expect(result).not.toBe(inputBuffer); + }); + + it('should bypass worker and return converted buffer when no candidate tokens are present in string input', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputString = 'function multiply(a, b) { return a * b; }'; + const result = await transformer.transformData('src/math.js', inputString, { + skipLinker: true, + }); + + expect(Buffer.from(result).toString('utf-8')).toBe(inputString); + }); + + it('should dispatch to worker when candidate tokens are present in string input', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + const inputString = 'export class MyService { static ɵprov = true; }'; + const result = await transformer.transformData('src/service.js', inputString, { + skipLinker: true, + }); + const text = Buffer.from(result).toString('utf-8'); + + expect(text).toContain('let MyService = /*#__PURE__*/ (() => {'); + }); + + it('should not query sideEffects when no candidate tokens are present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + let queried = false; + const inputBuffer = Buffer.from('function add(a, b) { return a + b; }', 'utf-8'); + await transformer.transformData('src/math.js', inputBuffer, { + skipLinker: true, + sideEffects: async () => { + queried = true; + + return false; + }, + }); + + expect(queried).toBeFalse(); + }); + + it('should not query sideEffects when primary tokens are present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + let queried = false; + const input = 'export class MyService { static ɵprov = true; }'; + await transformer.transformData('src/service.js', input, { + skipLinker: true, + sideEffects: async () => { + queried = true; + + return false; + }, + }); + + expect(queried).toBeFalse(); + }); + + it('should query sideEffects for @angular/ packages and dispatch to worker when sideEffects is false', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + let queryCount = 0; + const inputBuffer = Buffer.from('export const foo = someCall();', 'utf-8'); + const result = await transformer.transformData( + '/node_modules/@angular/core/fesm2022/index.mjs', + inputBuffer, + { + skipLinker: true, + sideEffects: async () => { + queryCount++; + + return false; + }, + }, + ); + + expect(queryCount).toBe(1); + expect(result).not.toBe(inputBuffer); + }); + + it('should query sideEffects for decorator tokens and evaluate at most once', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + let queryCount = 0; + const inputBuffer = Buffer.from('const MyClass = __decorate([], class {});', 'utf-8'); + const result = await transformer.transformData('src/class.js', inputBuffer, { + skipLinker: true, + sideEffects: async () => { + queryCount++; + + return false; + }, + }); + + expect(queryCount).toBe(1); + expect(result).not.toBe(inputBuffer); + }); + + it('should query sideEffects and wrap decorators when both primary and decorator tokens are present', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + let queryCount = 0; + const input = ` + let MyService = class MyService { static ɵprov = true; }; + MyService = __decorate([], MyService); + `; + const result = await transformer.transformData('src/service.js', input, { + skipLinker: true, + sideEffects: async () => { + queryCount++; + + return false; + }, + }); + + expect(queryCount).toBe(1); + const text = Buffer.from(result).toString('utf-8'); + expect(text).toContain('let MyService = /*#__PURE__*/ (() => {'); + expect(text).toContain('__decorate'); + }); + + it('should query sideEffects when both primary and decorator tokens are present in Buffer', async () => { + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); + + let queryCount = 0; + const inputBuffer = Buffer.from( + 'let MyService = class MyService { static ɵprov = true; };\nMyService = __decorate([], MyService);', + 'utf-8', + ); + const result = await transformer.transformData('src/service.js', inputBuffer, { + skipLinker: true, + sideEffects: async () => { + queryCount++; + + return false; + }, + }); + + expect(queryCount).toBe(1); + const text = Buffer.from(result).toString('utf-8'); + expect(text).toContain('let MyService = /*#__PURE__*/ (() => {'); + expect(text).toContain('__decorate'); + }); + }); + + describe('maxConcurrency', () => { + it('should throw RangeError if maxConcurrency is less than 1', () => { + expect(() => { + new JavaScriptTransformer({ sourcemap: false, maxConcurrency: 0 }); + }).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + + expect(() => { + new JavaScriptTransformer({ sourcemap: false, maxConcurrency: -1 }); + }).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + }); + + it('should throw RangeError if maxConcurrency is not an integer', () => { + expect(() => { + new JavaScriptTransformer({ sourcemap: false, maxConcurrency: 1.5 }); + }).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + }); + + it('should allow omitting maxConcurrency', async () => { + transformer = new JavaScriptTransformer({ sourcemap: false }); + const result = await transformer.transformData('src/app.js', 'const x = 1;', { + skipLinker: true, + }); + expect(result).toBeDefined(); + }); + }); +}); diff --git a/packages/angular/build/src/tools/oxc/adjust-static-class-members_oxc_spec.ts b/packages/angular/build/src/tools/oxc/adjust-static-class-members_oxc_spec.ts new file mode 100644 index 000000000000..7fe8c68030fc --- /dev/null +++ b/packages/angular/build/src/tools/oxc/adjust-static-class-members_oxc_spec.ts @@ -0,0 +1,1103 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { transform } from './oxc-transform'; + +const NO_CHANGE = Symbol('NO_CHANGE'); + +function cleanCode(code: string): string { + return code.replace(/\s+/g, '').replace(/,([}\])])/g, '$1'); +} + +function testCase({ + input, + expected, + options, +}: { + input: string; + expected: string | typeof NO_CHANGE; + options?: { wrapDecorators?: boolean }; +}): jasmine.ImplementationCallback { + return async () => { + const result = transform('test.js', input, { + sourcemap: false, + sideEffects: options?.wrapDecorators ? false : true, + pureAnnotate: false, + }); + if (!result?.code) { + fail('Expected oxc-transform to return a transform result.'); + } else { + const actualClean = cleanCode(result.code); + const expectedClean = cleanCode(expected === NO_CHANGE ? input : expected); + expect(actualClean).toEqual(expectedClean); + } + }; +} + +describe('adjust-static-class-members oxc-transform implementation', () => { + it( + 'elides empty ctorParameters function expression static field', + testCase({ + input: ` + export class SomeClass {} + SomeClass.ctorParameters = function () { return []; }; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'elides non-empty ctorParameters function expression static field', + testCase({ + input: ` + export class SomeClass {} + SomeClass.ctorParameters = function () { return [{type: Injector}]; }; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'elides empty ctorParameters arrow expression static field', + testCase({ + input: ` + export class SomeClass {} + SomeClass.ctorParameters = () => []; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'elides non-empty ctorParameters arrow expression static field', + testCase({ + input: ` + export class SomeClass {} + SomeClass.ctorParameters = () => [{type: Injector}]; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'keeps ctorParameters static field without arrow/function expression', + testCase({ + input: ` + export class SomeClass {} + SomeClass.ctorParameters = 42; + `, + expected: ` + export let SomeClass = /*#__PURE__*/ (() => { + class SomeClass {} + SomeClass.ctorParameters = 42; + return SomeClass; + })(); + `, + }), + ); + + it( + 'elides empty decorators static field with array literal', + testCase({ + input: ` + export class SomeClass {} + SomeClass.decorators = []; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'elides non-empty decorators static field with array literal', + testCase({ + input: ` + export class SomeClass {} + SomeClass.decorators = [{ type: Injectable }]; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'keeps decorators static field without array literal', + testCase({ + input: ` + export class SomeClass {} + SomeClass.decorators = 42; + `, + expected: ` + export let SomeClass = /*#__PURE__*/ (() => { + class SomeClass {} + SomeClass.decorators = 42; + return SomeClass; + })(); + `, + }), + ); + + it( + 'elides empty propDecorators static field with object literal', + testCase({ + input: ` + export class SomeClass {} + SomeClass.propDecorators = {}; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'elides non-empty propDecorators static field with object literal', + testCase({ + input: ` + export class SomeClass {} + SomeClass.propDecorators = { 'ngIf': [{ type: Input }] }; + `, + expected: 'export class SomeClass {}', + }), + ); + + it( + 'keeps propDecorators static field without object literal', + testCase({ + input: ` + export class SomeClass {} + SomeClass.propDecorators = 42; + `, + expected: ` + export let SomeClass = /*#__PURE__*/ (() => { + class SomeClass {} + SomeClass.propDecorators = 42; + return SomeClass; + })(); + `, + }), + ); + + it( + 'does not wrap default exported class with no connected siblings', + testCase({ + // NOTE: This could technically have no changes but the default export splitting detection + // does not perform class property analysis currently. + input: ` + export default class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + `, + expected: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + export { CustomComponentEffects as default }; + `, + }), + ); + + it( + 'does wrap not default exported class with only side effect fields', + testCase({ + input: ` + export default class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = console.log('foo'); + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap class with only side effect fields', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = console.log('foo'); + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap class with only side effect native fields', + testCase({ + input: ` + class CustomComponentEffects { + static someFieldWithSideEffects = console.log('foo'); + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap class with only instance native fields', + testCase({ + input: ` + class CustomComponentEffects { + someFieldWithSideEffects = console.log('foo'); + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + `, + expected: NO_CHANGE, + }), + ); + + it( + 'wraps class with pure annotated side effect fields (#__PURE__)', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = /*#__PURE__*/ console.log('foo'); + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = /*#__PURE__*/ console.log('foo'); + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps class with pure annotated side effect native fields (#__PURE__)', + testCase({ + input: ` + class CustomComponentEffects { + static someFieldWithSideEffects = /*#__PURE__*/ console.log('foo'); + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + static someFieldWithSideEffects = /*#__PURE__*/ console.log('foo'); + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps class with pure annotated side effect fields (@__PURE__)', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = /*@__PURE__*/ console.log('foo'); + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = /*@__PURE__*/ console.log('foo'); + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps class with pure annotated side effect fields (@pureOrBreakMyCode)', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = /**@pureOrBreakMyCode*/ console.log('foo'); + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = + /**@pureOrBreakMyCode*/ console.log('foo'); + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps class with closure pure annotated side effect fields', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = /* @pureOrBreakMyCode */ console.log('foo'); + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = + /* @pureOrBreakMyCode */ console.log('foo'); + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps exported class with a pure static field', + testCase({ + input: ` + export class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + `, + expected: ` + export let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps exported class with a pure native static field', + testCase({ + input: ` + export class CustomComponentEffects { + static someField = 42; + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + `, + expected: ` + export let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + static someField = 42; + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps class with a basic literal static field', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + return CustomComponentEffects; + })(); + `, + }), + ); + + it( + 'wraps class with a pure static field', + testCase({ + input: ` + const SWITCH_TEMPLATE_REF_FACTORY__POST_R3__ = injectTemplateRef; + const SWITCH_TEMPLATE_REF_FACTORY = SWITCH_TEMPLATE_REF_FACTORY__POST_R3__; + class TemplateRef {} + TemplateRef.__NG_ELEMENT_ID__ = SWITCH_TEMPLATE_REF_FACTORY; + `, + expected: ` + const SWITCH_TEMPLATE_REF_FACTORY__POST_R3__ = injectTemplateRef; + const SWITCH_TEMPLATE_REF_FACTORY = SWITCH_TEMPLATE_REF_FACTORY__POST_R3__; + let TemplateRef = /*#__PURE__*/ (() => { + class TemplateRef {} + TemplateRef.__NG_ELEMENT_ID__ = SWITCH_TEMPLATE_REF_FACTORY; + return TemplateRef; + })(); + `, + }), + ); + + it( + 'wraps class with multiple pure static field', + testCase({ + input: ` + const SWITCH_TEMPLATE_REF_FACTORY__POST_R3__ = injectTemplateRef; + const SWITCH_TEMPLATE_REF_FACTORY = SWITCH_TEMPLATE_REF_FACTORY__POST_R3__; + class TemplateRef {} + TemplateRef.__NG_ELEMENT_ID__ = SWITCH_TEMPLATE_REF_FACTORY; + TemplateRef.someField = 42; + `, + expected: ` + const SWITCH_TEMPLATE_REF_FACTORY__POST_R3__ = injectTemplateRef; + const SWITCH_TEMPLATE_REF_FACTORY = SWITCH_TEMPLATE_REF_FACTORY__POST_R3__; + let TemplateRef = /*#__PURE__*/ (() => { + class TemplateRef {} + TemplateRef.__NG_ELEMENT_ID__ = SWITCH_TEMPLATE_REF_FACTORY; + TemplateRef.someField = 42; + return TemplateRef; + })(); + `, + }), + ); + + it( + 'does not wrap class with only some pure static fields', + testCase({ + input: ` + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + CustomComponentEffects.someFieldWithSideEffects = console.log('foo'); + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap class with only pure native static fields and some side effect static fields', + testCase({ + input: ` + class CustomComponentEffects { + static someField = 42; + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someFieldWithSideEffects = console.log('foo'); + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap class with only some pure native static fields', + testCase({ + input: ` + class CustomComponentEffects { + static someField = 42; + static someFieldWithSideEffects = console.log('foo'); + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap class with class decorators when wrapDecorators is false', + testCase({ + input: ` + let SomeClass = class SomeClass { + }; + SomeClass = __decorate([ + Dec() + ], SomeClass); + `, + expected: NO_CHANGE, + options: { wrapDecorators: false }, + }), + ); + + it( + 'wraps class with Angular ɵfac static field (esbuild)', + testCase({ + input: ` + var Comp2Component = class _Comp2Component { + static { + this.ɵfac = function Comp2Component_Factory(t) { + return new (t || _Comp2Component)(); + }; + } + }; + `, + expected: ` + var Comp2Component = /*#__PURE__*/ (() => { + let Comp2Component = class _Comp2Component { + static { + this.ɵfac = function Comp2Component_Factory(t) { + return new (t || _Comp2Component)(); + }; + } + }; + return Comp2Component; + })(); + `, + }), + ); + + it( + 'wraps class with class decorators when wrapDecorators is true (esbuild output)', + testCase({ + input: ` + var ExampleClass = class { + method() { + } + }; + __decorate([ + SomeDecorator() + ], ExampleClass.prototype, "method", null); + `, + expected: ` + var ExampleClass = /*#__PURE__*/ (() => { + let ExampleClass = class { + method() {} + }; + __decorate([SomeDecorator()], ExampleClass.prototype, "method", null); + return ExampleClass; + })(); + `, + options: { wrapDecorators: true }, + }), + ); + + it( + 'wraps class with class decorators when wrapDecorators is true', + testCase({ + input: ` + let SomeClass = class SomeClass { + }; + SomeClass = __decorate([ + SomeDecorator() + ], SomeClass); + `, + expected: ` + let SomeClass = /*#__PURE__*/ (() => { + let SomeClass = class SomeClass { + }; + SomeClass = __decorate([ + SomeDecorator() + ], SomeClass); + return SomeClass; + })(); + `, + options: { wrapDecorators: true }, + }), + ); + + it( + 'does not wrap class with constructor decorators when wrapDecorators is false', + testCase({ + input: ` + let SomeClass = class SomeClass { + constructor(foo) { } + }; + SomeClass = __decorate([ + __param(0, SomeDecorator) + ], SomeClass); + `, + expected: NO_CHANGE, + options: { wrapDecorators: false }, + }), + ); + + it( + 'wraps class with constructor decorators when wrapDecorators is true', + testCase({ + input: ` + let SomeClass = class SomeClass { + constructor(foo) { } + }; + SomeClass = __decorate([ + __param(0, SomeDecorator) + ], SomeClass); + `, + expected: ` + let SomeClass = /*#__PURE__*/ (() => { + let SomeClass = class SomeClass { + constructor(foo) { } + }; + SomeClass = __decorate([ + __param(0, SomeDecorator) + ], SomeClass); + return SomeClass; + })(); + `, + options: { wrapDecorators: true }, + }), + ); + + it( + 'does not wrap class with field decorators when wrapDecorators is false', + testCase({ + input: ` + class SomeClass { + constructor() { + this.foo = 42; + } + } + __decorate([ + SomeDecorator + ], SomeClass.prototype, "foo", void 0); + `, + expected: NO_CHANGE, + options: { wrapDecorators: false }, + }), + ); + + it( + 'wraps class with field decorators when wrapDecorators is true', + testCase({ + input: ` + class SomeClass { + constructor() { + this.foo = 42; + } + } + __decorate([ + SomeDecorator + ], SomeClass.prototype, "foo", void 0); + `, + expected: ` + let SomeClass = /*#__PURE__*/ (() => { + class SomeClass { + constructor() { + this.foo = 42; + } + } + __decorate([ + SomeDecorator + ], SomeClass.prototype, "foo", void 0); + return SomeClass; + })(); + `, + options: { wrapDecorators: true }, + }), + ); + + it( + 'wraps class with Angular ɵfac static field', + testCase({ + input: ` + class CommonModule { + } + CommonModule.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; + `, + expected: ` + let CommonModule = /*#__PURE__*/ (() => { + class CommonModule { + } + CommonModule.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; + return CommonModule; + })(); + `, + }), + ); + + it( + 'wraps class with Angular ɵfac static block (ES2022 + useDefineForClassFields: false)', + testCase({ + input: ` + class CommonModule { + static { this.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; } + static { this.ɵmod = ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); } + } + `, + expected: ` + let CommonModule = /*#__PURE__*/ (() => { + class CommonModule { + static { + this.ɵfac = function CommonModule_Factory(t) { + return new (t || CommonModule)(); + }; + } + static { + this.ɵmod = ɵngcc0.ɵɵdefineNgModule({ + type: CommonModule, + }); + } + } + return CommonModule; + })(); + `, + }), + ); + + it( + 'does not wrap class with side effect full static block (ES2022 + useDefineForClassFields: false)', + testCase({ + input: ` + class CommonModule { + static { globalThis.bar = 1 } + } + `, + expected: NO_CHANGE, + }), + ); + + it( + 'wraps class with Angular ɵmod static field', + testCase({ + input: ` + class CommonModule { + } + CommonModule.ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); + `, + expected: ` + let CommonModule = /*#__PURE__*/ (() => { + class CommonModule { + } + CommonModule.ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); + return CommonModule; + })(); + `, + }), + ); + + it( + 'wraps class with Angular ɵinj static field', + testCase({ + input: ` + class CommonModule { + } + CommonModule.ɵinj = /*@__PURE__*/ ɵngcc0.ɵɵdefineInjector({ providers: [ + { provide: NgLocalization, useClass: NgLocaleLocalization }, + ] }); + `, + expected: ` + let CommonModule = /*#__PURE__*/ (() => { + class CommonModule { + } + CommonModule.ɵinj = /*@__PURE__*/ ɵngcc0.ɵɵdefineInjector({ providers: [ + { + provide: NgLocalization, + useClass: NgLocaleLocalization + }, + ] }); + return CommonModule; + })(); + `, + }), + ); + + it( + 'wraps class with multiple Angular static fields', + testCase({ + input: ` + class CommonModule { + } + CommonModule.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; + CommonModule.ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); + CommonModule.ɵinj = /*@__PURE__*/ ɵngcc0.ɵɵdefineInjector({ providers: [ + { provide: NgLocalization, useClass: NgLocaleLocalization }, + ] }); + `, + expected: ` + let CommonModule = /*#__PURE__*/ (() => { + class CommonModule { + } + CommonModule.ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; + CommonModule.ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); + CommonModule.ɵinj = /*@__PURE__*/ ɵngcc0.ɵɵdefineInjector({ providers: [ + { + provide: NgLocalization, + useClass: NgLocaleLocalization + }, + ]}); + return CommonModule; + })(); + `, + }), + ); + + it( + 'wraps class with multiple Angular native static fields', + testCase({ + input: ` + class CommonModule { + static ɵfac = function CommonModule_Factory(t) { return new (t || CommonModule)(); }; + static ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ type: CommonModule }); + static ɵinj = ɵngcc0.ɵɵdefineInjector({ providers: [ + { provide: NgLocalization, useClass: NgLocaleLocalization }, + ] }); + } + `, + expected: ` + let CommonModule = /*#__PURE__*/ (() => { + class CommonModule { + static ɵfac = function CommonModule_Factory(t) { + return new (t || CommonModule)(); + }; + static ɵmod = /*@__PURE__*/ ɵngcc0.ɵɵdefineNgModule({ + type: CommonModule, + }); + static ɵinj = ɵngcc0.ɵɵdefineInjector({ + providers: [ + { + provide: NgLocalization, + useClass: NgLocaleLocalization, + }, + ], + }); + } + return CommonModule; + })(); + `, + }), + ); + + it( + 'wraps default exported class with pure static fields', + testCase({ + input: ` + export default class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + `, + expected: ` + let CustomComponentEffects = /*#__PURE__*/ (() => { + class CustomComponentEffects { + constructor(_actions) { + this._actions = _actions; + this.doThis = this._actions; + } + } + CustomComponentEffects.someField = 42; + return CustomComponentEffects; + })(); + export { CustomComponentEffects as default }; + `, + }), + ); + it( + 'supports class with empty static block and wraps pure static properties', + testCase({ + input: ` + class MyClass { + static {} + static ɵprov = 42; + } + `, + expected: ` + let MyClass = /*#__PURE__*/ (() => { + class MyClass { + static {} + static ɵprov = 42; + } + return MyClass; + })(); + `, + }), + ); + + it( + 'does not wrap class with non-empty static block', + testCase({ + input: ` + class MyClass { + static { console.log(1); } + static ɵprov = 42; + } + `, + expected: ` + class MyClass { + static { console.log(1); } + static ɵprov = 42; + } + `, + }), + ); + + it( + 'wraps adjacent class declarations without interleaving', + testCase({ + input: 'class A{static s=[1]}class B{static s=[2]}', + expected: ` + let A = /*#__PURE__*/ (() => { + class A { + static s = [1] + } + return A; + })(); + let B = /*#__PURE__*/ (() => { + class B { + static s = [2] + } + return B; + })(); + `, + }), + ); + + it( + 'wraps adjacent exported class declarations without interleaving', + testCase({ + input: 'export class A{static s=[1]}export class B{static s=[2]}', + expected: ` + export let A = /*#__PURE__*/ (() => { + class A { + static s = [1] + } + return A; + })(); + export let B = /*#__PURE__*/ (() => { + class B { + static s = [2] + } + return B; + })(); + `, + }), + ); + + it( + 'wraps adjacent default export and class declarations without interleaving', + testCase({ + input: 'export default class A{static s=[1]}class B{static s=[2]}', + expected: ` + let A = /*#__PURE__*/ (() => { + class A { + static s = [1] + } + return A; + })(); + export { A as default }; + let B = /*#__PURE__*/ (() => { + class B { + static s = [2] + } + return B; + })(); + `, + }), + ); + + it( + 'splits default export without interleaving with adjacent class declaration', + testCase({ + input: 'export default class A{}class B{static s=[2]}', + expected: ` + class A {} + export { A as default }; + let B = /*#__PURE__*/ (() => { + class B { + static s = [2] + } + return B; + })(); + `, + }), + ); + + it( + 'wraps adjacent variable class declarations without interleaving', + testCase({ + input: 'let A=class A{static s=[1]};class B{static s=[2]}', + expected: ` + let A = /*#__PURE__*/ (() => { + let A = class A { + static s = [1] + }; + return A; + })(); + let B = /*#__PURE__*/ (() => { + class B { + static s = [2] + } + return B; + })(); + `, + }), + ); +}); diff --git a/packages/angular/build/src/tools/oxc/adjust-typescript-enums_oxc_spec.ts b/packages/angular/build/src/tools/oxc/adjust-typescript-enums_oxc_spec.ts new file mode 100644 index 000000000000..82877cb3b2e3 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/adjust-typescript-enums_oxc_spec.ts @@ -0,0 +1,382 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { format } from 'prettier'; +import { transform } from './oxc-transform'; + +const NO_CHANGE = Symbol('NO_CHANGE'); + +function testCase({ + input, + expected, +}: { + input: string; + expected: string | typeof NO_CHANGE; +}): jasmine.ImplementationCallback { + return async () => { + const result = transform('test.js', input, { + sourcemap: false, + pureAnnotate: false, + }); + if (!result?.code) { + fail('Expected oxc-transform to return a transform result.'); + } else { + expect(await format(result.code, { parser: 'babel' })).toEqual( + await format(expected === NO_CHANGE ? input : expected, { parser: 'babel' }), + ); + } + }; +} + +describe('adjust-typescript-enums oxc-transform implementation', () => { + it( + 'wraps unexported TypeScript enums', + testCase({ + input: ` + var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})); + `, + expected: ` + var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush"; + ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy || {}); + `, + }), + ); + + it( + 'wraps exported TypeScript enums', + testCase({ + input: ` + export var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})); + `, + expected: ` + export var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush"; + ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy || {}); + `, + }), + ); + + it( + 'does not wrap exported TypeScript enums from CommonJS (<5.1)', + testCase({ + input: ` + var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy = exports.ChangeDetectionStrategy || (exports.ChangeDetectionStrategy = {})); + `, + expected: NO_CHANGE, + }), + ); + + it( + 'wraps exported TypeScript enums from CommonJS (5.1+)', + testCase({ + input: ` + var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy || (exports.ChangeDetectionStrategy = ChangeDetectionStrategy = {})); + `, + expected: ` + var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush"; + ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy || (exports.ChangeDetectionStrategy = ChangeDetectionStrategy = {})); + `, + }), + ); + + it( + 'wraps TypeScript enums with custom numbering', + testCase({ + input: ` + export var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 5] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 8] = "Default"; + })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})); + `, + expected: ` + export var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 5)] = "OnPush"; + ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 8)] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy || {}); + `, + }), + ); + + it( + 'wraps string-based TypeScript enums', + testCase({ + input: ` + var NotificationKind; + (function (NotificationKind) { + NotificationKind["NEXT"] = "N"; + NotificationKind["ERROR"] = "E"; + NotificationKind["COMPLETE"] = "C"; + })(NotificationKind || (NotificationKind = {})); + `, + expected: ` + var NotificationKind = /*#__PURE__*/ (function (NotificationKind) { + NotificationKind["NEXT"] = "N"; + NotificationKind["ERROR"] = "E"; + NotificationKind["COMPLETE"] = "C"; + return NotificationKind; + })(NotificationKind || {}); + `, + }), + ); + + it( + 'wraps enums that were renamed due to scope hoisting', + testCase({ + input: ` + var NotificationKind$1; + (function (NotificationKind) { + NotificationKind["NEXT"] = "N"; + NotificationKind["ERROR"] = "E"; + NotificationKind["COMPLETE"] = "C"; + })(NotificationKind$1 || (NotificationKind$1 = {})); + `, + expected: ` + var NotificationKind$1 = /*#__PURE__*/ (function (NotificationKind) { + NotificationKind["NEXT"] = "N"; + NotificationKind["ERROR"] = "E"; + NotificationKind["COMPLETE"] = "C"; + return NotificationKind; + })(NotificationKind$1 || {}); + `, + }), + ); + + it( + 'maintains multi-line comments', + testCase({ + input: ` + /** + * Supported http methods. + * @deprecated use @angular/common/http instead + */ + var RequestMethod; + (function (RequestMethod) { + RequestMethod[RequestMethod["Get"] = 0] = "Get"; + RequestMethod[RequestMethod["Post"] = 1] = "Post"; + RequestMethod[RequestMethod["Put"] = 2] = "Put"; + RequestMethod[RequestMethod["Delete"] = 3] = "Delete"; + RequestMethod[RequestMethod["Options"] = 4] = "Options"; + RequestMethod[RequestMethod["Head"] = 5] = "Head"; + RequestMethod[RequestMethod["Patch"] = 6] = "Patch"; + })(RequestMethod || (RequestMethod = {})); + `, + expected: ` + /** + * Supported http methods. + * @deprecated use @angular/common/http instead + */ + var RequestMethod = /*#__PURE__*/ (function (RequestMethod) { + RequestMethod[(RequestMethod["Get"] = 0)] = "Get"; + RequestMethod[(RequestMethod["Post"] = 1)] = "Post"; + RequestMethod[(RequestMethod["Put"] = 2)] = "Put"; + RequestMethod[(RequestMethod["Delete"] = 3)] = "Delete"; + RequestMethod[(RequestMethod["Options"] = 4)] = "Options"; + RequestMethod[(RequestMethod["Head"] = 5)] = "Head"; + RequestMethod[(RequestMethod["Patch"] = 6)] = "Patch"; + return RequestMethod; + })(RequestMethod || {}); + `, + }), + ); + + it( + 'does not wrap TypeScript enums with side effect values', + testCase({ + input: ` + export var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = console.log('foo'); + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})); + `, + expected: NO_CHANGE, + }), + ); + + it( + 'does not wrap object literals similar to TypeScript enums', + testCase({ + input: ` + const RendererStyleFlags3 = { + Important: 1, + DashCase: 2, + }; + if (typeof RendererStyleFlags3 === 'object') { + RendererStyleFlags3[RendererStyleFlags3.Important] = 'DashCase'; + } + RendererStyleFlags3[RendererStyleFlags3.Important] = 'Important'; + `, + expected: NO_CHANGE, + }), + ); + + it( + 'wraps TypeScript enums', + testCase({ + input: ` + var ChangeDetectionStrategy; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})); + `, + expected: ` + var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush"; + ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy || {}); + `, + }), + ); + + it( + 'should wrap TypeScript enums if the declaration identifier has been renamed to avoid collisions', + testCase({ + input: ` + var ChangeDetectionStrategy$1; + (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy$1 || (ChangeDetectionStrategy$1 = {})); + `, + expected: ` + var ChangeDetectionStrategy$1 = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy$1 || {}); + `, + }), + ); + + it( + 'handles TypeScript enums with chained exports assignment (angular-split / shared-docs pattern)', + testCase({ + input: ` + var Area; + (function (a1) { + a1[a1["areaAfter"] = 0] = "areaAfter"; + a1[a1["preserveOtherCategoryOrder"] = 1] = "preserveOtherCategoryOrder"; + })(Area || (Area = exports.Area = {})); + `, + expected: ` + var Area = /*#__PURE__*/ (function (a1) { + a1[(a1["areaAfter"] = 0)] = "areaAfter"; + a1[(a1["preserveOtherCategoryOrder"] = 1)] = "preserveOtherCategoryOrder"; + return a1; + })(Area || (exports.Area = {})); + `, + }), + ); + + it( + 'handles TypeScript enums wrapped in parentheses', + testCase({ + input: ` + var ChangeDetectionStrategy; + ((function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; + ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; + })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {}))); + `, + expected: ` + var ChangeDetectionStrategy = /*#__PURE__*/ (function (ChangeDetectionStrategy) { + ChangeDetectionStrategy[(ChangeDetectionStrategy["OnPush"] = 0)] = "OnPush"; + ChangeDetectionStrategy[(ChangeDetectionStrategy["Default"] = 1)] = "Default"; + return ChangeDetectionStrategy; + })(ChangeDetectionStrategy || {}); + `, + }), + ); + + it( + 'wraps Crockford-style TypeScript enum IIFE without leaving dangling parentheses', + testCase({ + input: ` + var HDirection; + (function (HDirection) { + HDirection[HDirection['Backwards'] = -1] = 'Backwards'; + HDirection[HDirection['Forwards'] = 1] = 'Forwards'; + }(HDirection || (HDirection = {}))); + const nextStatement = true; + `, + expected: ` + var HDirection = /*#__PURE__*/ (function (HDirection) { + HDirection[(HDirection['Backwards'] = -1)] = 'Backwards'; + HDirection[(HDirection['Forwards'] = 1)] = 'Forwards'; + return HDirection; + }(HDirection || {})); + + const nextStatement = true; + `, + }), + ); + + it( + 'wraps TypeScript enum IIFE with multiple nested parentheses', + testCase({ + input: ` + var Foo; + (((function (Foo) { + Foo[Foo['A'] = 0] = 'A'; + }(Foo || (Foo = {}))))); + `, + expected: ` + var Foo = /*#__PURE__*/ (function (Foo) { + Foo[(Foo['A'] = 0)] = 'A'; + return Foo; + }(Foo || {})); + `, + }), + ); + + it( + 'wraps Crockford-style TypeScript enum IIFE with chained export assignments', + testCase({ + input: ` + var Foo; + (function (Foo) { + Foo[Foo['A'] = 0] = 'A'; + }(Foo || (Foo = exports.Foo = {}))); + `, + expected: ` + var Foo = /*#__PURE__*/ (function (Foo) { + Foo[(Foo['A'] = 0)] = 'A'; + return Foo; + }(Foo || (exports.Foo = {}))); + `, + }), + ); +}); diff --git a/packages/angular/build/src/tools/oxc/elide-angular-metadata_oxc_spec.ts b/packages/angular/build/src/tools/oxc/elide-angular-metadata_oxc_spec.ts new file mode 100644 index 000000000000..f4d6c09b6c19 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/elide-angular-metadata_oxc_spec.ts @@ -0,0 +1,213 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { transform } from './oxc-transform'; + +function cleanCode(code: string): string { + return code + .replace(/\s+/g, '') + .replace(/"/g, "'") + .replace(/;}/g, '}') + .replace(/,([}\])])/g, '$1'); +} + +function testCase({ + input, + expected, +}: { + input: string; + expected: string; +}): jasmine.ImplementationCallback { + return async () => { + const result = transform('test.js', input, { + sourcemap: false, + pureAnnotate: false, + }); + if (!result?.code) { + fail('Expected oxc-transform to return a transform result.'); + } else { + const actualClean = cleanCode(result.code); + const expectedClean = cleanCode(expected); + expect(actualClean).toEqual(expectedClean); + } + }; +} + +describe('elide-angular-metadata oxc-transform implementation', () => { + it( + 'elides pure annotated ɵsetClassMetadata', + testCase({ + input: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (function () { i0.ɵsetClassMetadata(Clazz, [{ + type: Component, + args: [{ + selector: 'app-lazy', + template: 'very lazy', + styles: [] + }] + }], null, null); })(); + `, + expected: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (function () { void 0 })(); + `, + }), + ); + + it( + 'elides JIT mode protected ɵsetClassMetadata', + testCase({ + input: ` + import { Component } from '@angular/core'; + export class SomeClass {} + (function () { (typeof ngJitMode === "undefined" || ngJitMode) && i0.ɵsetClassMetadata(SomeClass, [{ + type: Component, + args: [{ + selector: 'app-lazy', + template: 'very lazy', + styles: [] + }] + }], null, null); })();`, + expected: ` + import { Component } from '@angular/core'; + export class SomeClass {} + (function () { (typeof ngJitMode === "undefined" || ngJitMode) && void 0 })();`, + }), + ); + + it( + 'elides ɵsetClassMetadata inside an arrow-function-based IIFE', + testCase({ + input: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (() => { i0.ɵsetClassMetadata(Clazz, [{ + type: Component, + args: [{ + selector: 'app-lazy', + template: 'very lazy', + styles: [] + }] + }], null, null); })(); + `, + expected: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (() => { void 0 })(); + `, + }), + ); + + it( + 'elides pure annotated ɵsetClassMetadataAsync', + testCase({ + input: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (function () { + i0.ɵsetClassMetadataAsync(SomeClass, + function () { return [import("./cmp-a").then(function (m) { return m.CmpA; })]; }, + function (CmpA) { i0.ɵsetClassMetadata(SomeClass, [{ + type: Component, + args: [{ + selector: 'test-cmp', + standalone: true, + imports: [CmpA, LocalDep], + template: '{#defer}{/defer}', + }] + }], null, null); }); + })(); + `, + expected: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (function () { void 0 })(); + `, + }), + ); + + it( + 'elides JIT mode protected ɵsetClassMetadataAsync', + testCase({ + input: ` + import { Component } from '@angular/core'; + export class SomeClass {} + (function () { + (typeof ngJitMode === "undefined" || ngJitMode) && i0.ɵsetClassMetadataAsync(SomeClass, + function () { return [import("./cmp-a").then(function (m) { return m.CmpA; })]; }, + function (CmpA) { i0.ɵsetClassMetadata(SomeClass, [{ + type: Component, + args: [{ + selector: 'test-cmp', + standalone: true, + imports: [CmpA, LocalDep], + template: '{#defer}{/defer}', + }] + }], null, null); }); + })(); + `, + expected: ` + import { Component } from '@angular/core'; + export class SomeClass {} + (function () { (typeof ngJitMode === "undefined" || ngJitMode) && void 0 })(); + `, + }), + ); + + it( + 'elides arrow-function-based ɵsetClassMetadataAsync', + testCase({ + input: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (() => { + i0.ɵsetClassMetadataAsync(SomeClass, + () => [import("./cmp-a").then(m => m.CmpA)], + (CmpA) => { i0.ɵsetClassMetadata(SomeClass, [{ + type: Component, + args: [{ + selector: 'test-cmp', + standalone: true, + imports: [CmpA, LocalDep], + template: '{#defer}{/defer}', + }] + }], null, null); }); + })(); + `, + expected: ` + import { Component } from '@angular/core'; + export class SomeClass {} + /*@__PURE__*/ (() => { void 0 })(); + `, + }), + ); + + it( + 'elides ɵsetClassDebugInfo', + testCase({ + input: ` + import { Component } from '@angular/core'; + class SomeClass {} + (() => { + (typeof ngDevMode === 'undefined' || ngDevMode) && + i0.ɵsetClassDebugInfo(SomeClass, { className: 'SomeClass' }); + })(); + `, + expected: ` + import { Component } from "@angular/core"; + class SomeClass {} + (() => { + (typeof ngDevMode === "undefined" || ngDevMode) && void 0; + })(); + `, + }), + ); +}); diff --git a/packages/angular/build/src/tools/oxc/oxc-transform.ts b/packages/angular/build/src/tools/oxc/oxc-transform.ts new file mode 100644 index 000000000000..89594d9462ef --- /dev/null +++ b/packages/angular/build/src/tools/oxc/oxc-transform.ts @@ -0,0 +1,749 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import './oxc-wasi-shim'; +import type { DecodedSourceMap } from '@ampproject/remapping'; +import { needsLinking } from '@angular/compiler-cli/linker'; +import type { BindingIdentifier, Class, Node } from '@oxc-project/types'; +import { MagicString } from 'magic-string'; +import { parseSync } from 'oxc-parser'; +import { OxcLinker } from '../angular/linker/oxc-linker'; +import { traversePostOrder } from './traversal'; + +export interface OxcTransformOptions { + sourcemap?: boolean; + sideEffects?: boolean; + topLevelSafeMode?: boolean; + pureAnnotate?: boolean; + link?: boolean; + jit?: boolean; + advancedOptimizations?: boolean; +} + +/** + * A set of constructor names that are considered to be side-effect free. + */ +const sideEffectFreeConstructors = new Set(['InjectionToken']); + +/** + * A set of TypeScript helper function names used by the helper name matcher utility function. + */ +const tslibHelpers = new Set([ + '__extends', + '__assign', + '__rest', + '__decorate', + '__param', + '__esDecorate', + '__runInitializers', + '__propKey', + '__setFunctionName', + '__metadata', + '__awaiter', + '__generator', + '__exportStar', + '__values', + '__read', + '__privateGet', + '__privateSet', + '__privateMethod', + '__addDisposableResource', + '__disposeResources', +]); + +/** + * Determines whether an identifier name matches one of the TypeScript helper function names. + * + * @param name The identifier name to check. + * @returns True if the name matches a TypeScript helper name; otherwise, false. + */ +function isTslibHelperName(name: string): boolean { + const nameParts = name.split('$'); + const originalName = nameParts[0]; + + if (nameParts.length > 2 || (nameParts.length === 2 && !/^\d+$/.test(nameParts[1]))) { + return false; + } + + return tslibHelpers.has(originalName); +} + +/** + * A set of Babel helper function names that are intended to cause side effects. + */ +const babelHelpers = new Set(['_defineProperty']); + +/** + * Determines whether an identifier name matches one of the Babel helper function names. + * + * @param name The identifier name to check. + * @returns True if the name matches a Babel helper name; otherwise, false. + */ +function isBabelHelperName(name: string): boolean { + return babelHelpers.has(name); +} + +/** + * A set of Angular static properties that should be wrapped in pure IIFE statements. + */ +const angularStaticsToWrap = new Set([ + 'ɵcmp', + 'ɵdir', + 'ɵfac', + 'ɵinj', + 'ɵmod', + 'ɵpipe', + 'ɵprov', + 'INJECTOR_KEY', +]); + +/** + * A set of Angular metadata decorator functions that can be elided. + */ +const angularMetadataFunctions = new Set([ + 'ɵsetClassMetadata', + 'ɵsetClassMetadataAsync', + 'ɵsetClassDebugInfo', +]); + +/** + * A map of static properties and their matcher predicate functions to check if they + * can be safely elided from class declarations. + */ +const angularStaticsToElide: Record boolean> = { + 'ctorParameters'(node) { + return node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression'; + }, + 'decorators'(node) { + return node.type === 'ArrayExpression'; + }, + 'propDecorators'(node) { + return node.type === 'ObjectExpression'; + }, +}; + +/** + * Determines whether an AST node is considered safe for pure evaluation (side-effect free). + * + * @param node The AST node to check. + * @returns True if the node is side-effect free; otherwise, false. + */ +function isPure(node: Node): boolean { + switch (node.type) { + case 'Identifier': + case 'Literal': + return true; + case 'BinaryExpression': + case 'LogicalExpression': + return isPure(node.left) && isPure(node.right); + case 'UnaryExpression': + return isPure(node.argument); + case 'MemberExpression': + return isPure(node.object) && (!node.computed || isPure(node.property)); + case 'ObjectExpression': + return node.properties.every((p) => p.type === 'Property' && isPure(p.value)); + case 'ArrayExpression': + return node.elements.every((e) => !e || isPure(e)); + case 'ParenthesizedExpression': + return isPure(node.expression); + default: + return false; + } +} + +/** + * Recursively unwraps any ParenthesizedExpression wrapper nodes to get the inner expression. + * + * @param node The potentially parenthesized AST node. + * @returns The inner non-parenthesized AST node. + */ +function unwrapParentheses(node: Node): Node { + while (node && node.type === 'ParenthesizedExpression') { + node = node.expression; + } + + return node; +} + +/** + * Determines whether a class static property assignment is safe to be wrapped in a pure IIFE. + * + * @param propertyName The name of the property. + * @param assignmentValue The AST node representing the value assigned. + * @param code The source code of the file. + * @returns True if the property can be wrapped safely; otherwise, false. + */ +function canWrapProperty(propertyName: string, assignmentValue: Node, code: string): boolean { + if (angularStaticsToWrap.has(propertyName)) { + return true; + } + + const prefix = code.substring(Math.max(0, assignmentValue.start - 100), assignmentValue.start); + if (/(\/\*[\s\S]*?(?:@__PURE__|#__PURE__|@pureOrBreakMyCode)[\s\S]*?\*\/)\s*$/.test(prefix)) { + return true; + } + + return isPure(assignmentValue); +} + +/** + * Analyzes static properties inside a class body to determine if the class has any + * side-effecting static property initializers. + * + * @param classNode The Class AST node. + * @param code The source code of the file. + * @returns True if the class static properties are pure and can be wrapped; otherwise, false. + */ +function analyzeClassStaticProperties(classNode: Node, code: string): boolean { + let shouldWrap = false; + const body = (classNode as Class).body.body; + for (const element of body) { + if (element.type === 'PropertyDefinition') { + if (!element.static) { + continue; + } + + const key = element.key; + const value = element.value; + if (key.type === 'Identifier' && (!value || canWrapProperty(key.name, value, code))) { + shouldWrap = true; + } else { + shouldWrap = false; + break; + } + } else if (element.type === 'StaticBlock') { + const blockBody = element.body; + if (blockBody.length === 0) { + continue; + } + if (blockBody.length > 1) { + shouldWrap = false; + break; + } + const expressionStatement = blockBody[0]; + if (expressionStatement && expressionStatement.type === 'ExpressionStatement') { + const assignment = expressionStatement.expression; + if ( + assignment && + assignment.type === 'AssignmentExpression' && + assignment.left.type === 'MemberExpression' + ) { + const left = assignment.left; + if (left.object.type === 'ThisExpression' && left.property.type === 'Identifier') { + if (canWrapProperty(left.property.name, assignment.right, code)) { + shouldWrap = true; + continue; + } + } + } + } + shouldWrap = false; + break; + } + } + + return shouldWrap; +} + +/** + * Executes a single-pass optimized transformation using oxc-parser and magic-string. + * Performs typescript enum wrapping, static class members elision/wrapping, angular metadata elision, + * and top-level pure function annotations. + * + * @param filename The absolute path of the file being transformed. + * @param code The string source content of the file. + * @param options Configuration options specifying which optimization steps to run. + * @returns The transformed code string and an optional source map. + */ +// eslint-disable-next-line max-lines-per-function +export function transform(filename: string, code: string, options: OxcTransformOptions) { + const { program } = parseSync(filename, code, { range: true }); + const source = new MagicString(code); + + const shouldLink = options.link && needsLinking(filename, code); + const linker = shouldLink ? new OxcLinker(filename, code, options.jit) : undefined; + + const advancedOptimizations = options.advancedOptimizations ?? true; + const sideEffectFree = options.sideEffects === false; + const topLevelSafeMode = options.topLevelSafeMode ?? false; + const wrapDecorators = sideEffectFree; + const pureAnnotate = options.pureAnnotate ?? true; + + /** + * Scans backwards from the specified start index to check if a pure comment (e.g. `/*@__PURE__*\/`) + * already precedes the node. + * + * @param start The index where the node starts. + * @returns True if a pure comment is already present; otherwise, false. + */ + function hasPureComment(start: number): boolean { + let i = start - 1; + while (i >= 0 && /\s/.test(code[i])) { + i--; + } + if (i < 1 || code[i] !== '/' || code[i - 1] !== '*') { + return false; + } + const commentEnd = i + 1; + const commentStart = code.lastIndexOf('/*', commentEnd); + if (commentStart === -1) { + return false; + } + const commentContent = code.substring(commentStart + 2, commentEnd - 2); + + return commentContent.includes('@__PURE__') || commentContent.includes('#__PURE__'); + } + + const editedRanges: { start: number; end: number }[] = []; + + /** + * Records a range in the source code that has been modified, preventing subsequent nested mutations. + * + * @param start The start index of the modified range. + * @param end The end index of the modified range. + */ + function markEdited(start: number, end: number) { + editedRanges.push({ start, end }); + } + + /** + * Checks if the specified range falls inside an already modified section of code. + * + * @param start The start index of the range. + * @param end The end index of the range. + * @returns True if the range is already edited; otherwise, false. + */ + function isAlreadyEdited(start: number, end: number): boolean { + return editedRanges.some((r) => start >= r.start && end <= r.end); + } + + /** + * Scans and rewrites TypeScript emitted enum declarations in the statement block. + * Wraps enum statements inside a pure IIFE assignable directly to the enum variable. + * + * @param body The array of statement AST nodes to process. + */ + function adjustTypeScriptEnumsInStatements(body: Node[]) { + for (let i = 0; i < body.length - 1; i++) { + const statement = body[i]; + let declStatement = statement; + if ( + statement.type === 'ExportNamedDeclaration' && + statement.declaration?.type === 'VariableDeclaration' + ) { + declStatement = statement.declaration; + } + + if ( + declStatement.type !== 'VariableDeclaration' || + declStatement.kind !== 'var' || + declStatement.declarations.length !== 1 + ) { + continue; + } + const decl = declStatement.declarations[0]; + if (decl.init || decl.id.type !== 'Identifier') { + continue; + } + + const nextStatement = body[i + 1]; + if (nextStatement.type !== 'ExpressionStatement') { + continue; + } + + const nextExpr = unwrapParentheses(nextStatement.expression); + if (nextExpr.type !== 'CallExpression' || nextExpr.arguments.length !== 1) { + continue; + } + + const arg = unwrapParentheses(nextExpr.arguments[0]); + if (arg.type !== 'LogicalExpression' || arg.operator !== '||') { + continue; + } + + const argLeft = unwrapParentheses(arg.left); + if (argLeft.type !== 'Identifier' || argLeft.name !== decl.id.name) { + continue; + } + + const rightCallArgument = unwrapParentheses(arg.right); + if (rightCallArgument.type !== 'AssignmentExpression') { + continue; + } + + const callee = unwrapParentheses(nextExpr.callee); + if ( + callee.type !== 'FunctionExpression' || + callee.params.length !== 1 || + !callee.body || + callee.body.type !== 'BlockStatement' + ) { + continue; + } + + const param = callee.params[0]; + if (param.type !== 'Identifier') { + continue; + } + const paramName = (param as BindingIdentifier).name; + + // Check if all statements in body are pure assignments + let hasElements = false; + let allPure = true; + for (const enumStatement of callee.body.body) { + if (enumStatement.type !== 'ExpressionStatement') { + allPure = false; + break; + } + + const enumValueAssignment = unwrapParentheses(enumStatement.expression); + if ( + enumValueAssignment.type !== 'AssignmentExpression' || + !isPure(enumValueAssignment.right) + ) { + allPure = false; + break; + } + + hasElements = true; + } + + if (!allPure || !hasElements) { + continue; + } + + // 1. Remove leading/trailing characters/parentheses of the expression statement + source.remove(nextStatement.start, nextExpr.start); + source.remove(nextExpr.end, nextStatement.end); + markEdited(nextStatement.start, nextStatement.end); + + // 2. Add return statement inside IIFE body + source.appendRight(callee.body.end - 1, `; return ${paramName};`); + + // 3. Remove `Name = ` assignment in arguments if it's a simple identifier + if (rightCallArgument.left.type === 'Identifier') { + let replacement = code.substring( + rightCallArgument.right.start, + rightCallArgument.right.end, + ); + if (unwrapParentheses(rightCallArgument.right).type === 'AssignmentExpression') { + replacement = `(${replacement})`; + } + source.overwrite(arg.right.start, arg.right.end, replacement); + markEdited(arg.right.start, arg.right.end); + } + + // 4. Move IIFE to the var initializer + source.move(nextExpr.start, nextExpr.end, decl.id.end); + source.appendLeft(decl.id.end, ' = /*#__PURE__*/ '); + } + } + + /** + * Scans and rewrites static class member initializers in the statement block. + * Groups externalized class static assignments into pure wrappers or elides them when safe. + * + * @param body The array of statement AST nodes to process. + */ + function adjustStaticMembersInStatements(body: Node[]) { + for (let i = 0; i < body.length; i++) { + const statement = body[i]; + let classNode: Node | null = null; + let isExportDefault = false; + let isExportNamed = false; + let isVariableClass = false; + let classIdName = ''; + if (statement.type === 'ClassDeclaration') { + classNode = statement; + classIdName = classNode.id?.name || ''; + } else if ( + statement.type === 'ExportNamedDeclaration' && + statement.declaration?.type === 'ClassDeclaration' + ) { + classNode = statement.declaration; + classIdName = classNode.id?.name || ''; + isExportNamed = true; + } else if ( + statement.type === 'ExportDefaultDeclaration' && + statement.declaration?.type === 'ClassDeclaration' + ) { + classNode = statement.declaration; + classIdName = classNode.id?.name || ''; + isExportDefault = true; + } else if (statement.type === 'VariableDeclaration' && statement.declarations.length === 1) { + const decl = statement.declarations[0]; + if (decl.init && decl.init.type === 'ClassExpression' && decl.id.type === 'Identifier') { + classNode = decl.init; + classIdName = decl.id.name; + isVariableClass = true; + } + } + + if (!classNode || !classIdName) { + continue; + } + + const wrapStatementPaths: { statement: Node; type: 'wrap' | 'decorate' | 'elide' }[] = []; + let hasPotentialSideEffects = false; + + for (let j = i + 1; j < body.length; j++) { + const nextStatement = body[j]; + if (nextStatement.type !== 'ExpressionStatement') { + break; + } + + const nextExpression = nextStatement.expression; + + // Case 1: __decorate(...) + if (nextExpression.type === 'CallExpression') { + if ( + nextExpression.callee.type !== 'Identifier' || + nextExpression.callee.name !== '__decorate' + ) { + break; + } + + if (wrapDecorators) { + wrapStatementPaths.push({ statement: nextStatement, type: 'decorate' }); + } else { + hasPotentialSideEffects = true; + } + continue; + } + + // Case 2: AssignmentExpression + if (nextExpression.type !== 'AssignmentExpression') { + break; + } + + const left = nextExpression.left; + + if (left.type === 'Identifier') { + if ( + left.name !== classIdName || + nextExpression.right.type !== 'CallExpression' || + nextExpression.right.callee.type !== 'Identifier' || + nextExpression.right.callee.name !== '__decorate' + ) { + break; + } + + if (wrapDecorators) { + wrapStatementPaths.push({ statement: nextStatement, type: 'decorate' }); + } else { + hasPotentialSideEffects = true; + } + continue; + } + + if ( + left.type !== 'MemberExpression' || + left.object.type !== 'Identifier' || + left.object.name !== classIdName || + left.property.type !== 'Identifier' + ) { + break; + } + + const propertyName = left.property.name; + const assignmentValue = nextExpression.right; + + if (angularStaticsToElide[propertyName]?.(assignmentValue)) { + wrapStatementPaths.push({ statement: nextStatement, type: 'elide' }); + } else if (canWrapProperty(propertyName, assignmentValue, code)) { + wrapStatementPaths.push({ statement: nextStatement, type: 'wrap' }); + } else { + hasPotentialSideEffects = true; + } + } + + // Check class body static properties + const shouldWrapClassStaticProperties = analyzeClassStaticProperties(classNode, code); + + // Perform elisions immediately + for (const item of wrapStatementPaths) { + if (item.type === 'elide') { + source.remove(item.statement.start, item.statement.end); + markEdited(item.statement.start, item.statement.end); + } + } + + const activeWrapPaths = wrapStatementPaths.filter( + (p) => p.type === 'wrap' || p.type === 'decorate', + ); + + if ( + !hasPotentialSideEffects && + (activeWrapPaths.length > 0 || shouldWrapClassStaticProperties) + ) { + const lastStatement = + activeWrapPaths.length > 0 + ? activeWrapPaths[activeWrapPaths.length - 1].statement + : classNode; + + if (isExportDefault) { + // 1. Remove `export default ` + source.overwrite(statement.start, classNode.start, ''); + // 2. Wrap in IIFE + source.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`); + source.appendLeft( + lastStatement.end, + `\nreturn ${classIdName};\n})();\nexport { ${classIdName} as default };`, + ); + } else if (isExportNamed) { + // 1. Export is kept, turn `class` into `let ClassName = IIFE` + source.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`); + source.appendLeft(lastStatement.end, `\nreturn ${classIdName};\n})();`); + } else if (isVariableClass) { + // 2. Wrap class inside init: `(() => { let ClassName = class ClassName {}; return ClassName; })()` + source.appendRight(classNode.start, `/*#__PURE__*/ (() => {\nlet ${classIdName} = `); + const terminator = activeWrapPaths.length === 0 ? ';' : ''; + const iifeClosing = activeWrapPaths.length === 0 ? '})()' : '})();'; + source.appendLeft( + lastStatement.end, + `${terminator}\nreturn ${classIdName};\n${iifeClosing}`, + ); + } else { + // Standard ClassDeclaration + source.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`); + source.appendLeft(lastStatement.end, `\nreturn ${classIdName};\n})();`); + } + + markEdited(statement.start, lastStatement.end); + + // Fast-forward outer loop index to skip the statements we wrapped + i += wrapStatementPaths.length; + } else if (isExportDefault && !hasPotentialSideEffects) { + // Splitting default export even when not wrapped + source.overwrite(statement.start, classNode.start, ''); + source.appendLeft(classNode.end, `\nexport { ${classIdName} as default };`); + markEdited(statement.start, classNode.end); + } + } + } + + traversePostOrder(program, (node, { functionDepth, classDepth, parentFunc }) => { + switch (node.type) { + case 'Program': + case 'BlockStatement': + if (advancedOptimizations) { + adjustTypeScriptEnumsInStatements(node.body); + adjustStaticMembersInStatements(node.body); + } + break; + + case 'CallExpression': { + if (isAlreadyEdited(node.start, node.end)) { + return; + } + + if (linker) { + const linkedCode = linker.linkCallExpression(node); + if (linkedCode !== undefined) { + source.overwrite(node.start, node.end, linkedCode); + markEdited(node.start, node.end); + + return; + } + } + + if (!advancedOptimizations) { + return; + } + + // 1. Elide Angular Metadata check + let calleeName: string | undefined; + if (node.callee.type === 'Identifier') { + calleeName = node.callee.name; + } else if ( + node.callee.type === 'MemberExpression' && + node.callee.property.type === 'Identifier' + ) { + calleeName = node.callee.property.name; + } + + if (calleeName && angularMetadataFunctions.has(calleeName)) { + if ( + parentFunc && + (parentFunc.type === 'FunctionExpression' || + parentFunc.type === 'ArrowFunctionExpression') + ) { + source.overwrite(node.start, node.end, 'void 0'); + markEdited(node.start, node.end); + + return; + } + } + + // 2. Mark Top-Level Pure Functions check + if (!pureAnnotate || functionDepth > 0 || classDepth > 0 || topLevelSafeMode) { + return; + } + + const callee = unwrapParentheses(node.callee); + if ( + (callee.type === 'FunctionExpression' || callee.type === 'ArrowFunctionExpression') && + node.arguments.length !== 0 + ) { + return; + } + + if ( + callee.type === 'Identifier' && + (isTslibHelperName(callee.name) || isBabelHelperName(callee.name)) + ) { + return; + } + + if (!hasPureComment(node.start)) { + source.appendLeft(node.start, '/*#__PURE__*/ '); + } + break; + } + + case 'NewExpression': { + if ( + !advancedOptimizations || + !pureAnnotate || + functionDepth > 0 || + classDepth > 0 || + isAlreadyEdited(node.start, node.end) + ) { + return; + } + + if (!topLevelSafeMode) { + if (!hasPureComment(node.start)) { + source.appendLeft(node.start, '/*#__PURE__*/ '); + } + + return; + } + + const newCallee = node.callee; + if (newCallee.type === 'Identifier' && sideEffectFreeConstructors.has(newCallee.name)) { + if (!hasPureComment(node.start)) { + source.appendLeft(node.start, '/*#__PURE__*/ '); + } + } + break; + } + } + }); + + let map: DecodedSourceMap | undefined; + if (options.sourcemap) { + const rawMap = source.generateDecodedMap({ hires: 'boundary', source: filename }); + map = { ...rawMap, version: 3 }; + } + + return { + code: source.toString(), + map, + }; +} diff --git a/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts b/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts new file mode 100644 index 000000000000..d1b02f6a1bdc --- /dev/null +++ b/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { transform } from './oxc-transform'; + +describe('oxc-transform', () => { + describe('sourcemaps', () => { + it('should generate a decoded sourcemap when sourcemap option is enabled', () => { + const input = 'var result = new SomeClass();'; + const result = transform('test.js', input, { sourcemap: true }); + + expect(result.map).toBeDefined(); + expect(result.map?.version).toBe(3); + expect(result.map?.sources).toContain('test.js'); + expect(result.map?.mappings.length).toBeGreaterThan(0); + }); + }); + + describe('linking and unified passes', () => { + const componentInput = ` + import * as i0 from "@angular/core"; + export class MyComponent {} + MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyComponent, + isStandalone: true, + selector: "my-cmp", + template: "Hello" + }); + `; + + it('should link partial component declarations when link option is enabled', () => { + const result = transform('test.js', componentInput, { link: true }); + expect(result.code).toContain('i0.ɵɵdefineComponent'); + expect(result.code).not.toContain('i0.ɵɵngDeclareComponent'); + }); + + it('should not link partial component declarations when link option is disabled', () => { + const result = transform('test.js', componentInput, { link: false }); + expect(result.code).not.toContain('i0.ɵɵdefineComponent'); + expect(result.code).toContain('i0.ɵɵngDeclareComponent'); + }); + + it('should perform linking and advanced optimizations simultaneously in a single pass', () => { + const input = ` + import * as i0 from "@angular/core"; + export class MyComponent { + static create() { + return new MyComponent(); + } + } + MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyComponent, + isStandalone: true, + selector: "my-cmp", + template: "Hello" + }); + `; + + const result = transform('test.js', input, { + link: true, + advancedOptimizations: true, + topLevelSafeMode: true, + }); + expect(result.code).toContain('i0.ɵɵdefineComponent'); + expect(result.code).not.toContain('i0.ɵɵngDeclareComponent'); + expect(result.code).toContain('let MyComponent = /*#__PURE__*/ (() => {'); + }); + }); +}); diff --git a/packages/angular/build/src/tools/oxc/oxc-wasi-shim.ts b/packages/angular/build/src/tools/oxc/oxc-wasi-shim.ts new file mode 100644 index 000000000000..3285052be059 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/oxc-wasi-shim.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Module, createRequire } from 'node:module'; + +// Workaround for an upstream incompatibility in `@oxc-parser/binding-wasm32-wasi` when +// falling back to WebAssembly (e.g., inside WebContainers or StackBlitz). +// +// `@oxc-parser/binding-wasm32-wasi` declares a dependency on `@emnapi/core@2.0.0-alpha.6`, +// but depends on `@napi-rs/wasm-runtime@~1.2.6` which expects `@emnapi/core@^1.7.1 || 2.0.0-beta.2`. +// 1. In `@napi-rs/wasm-runtime@1.2.6`, the runtime attempts to assign `initWorker` to +// `emnapi_shared.addonModule`, which is undefined in `alpha.6` where the property was named +// `napiModule`. Aliasing `addonModule` to `this.napiModule` on `Object.prototype` allows the +// WASI runtime to initialize successfully. +// 2. Under npm, `@napi-rs/wasm-runtime@1.2.6`'s peerDependencies cause npm to hoist `@emnapi/core@1.x` +// to top-level `node_modules` while nesting `2.0.0-alpha.6` under `@oxc-parser/binding-wasm32-wasi`, +// causing an ABI mismatch (`this.bridge.setLastError is not a function`). Under pnpm, `oxc-parser` +// does not declare `@oxc-parser/binding-wasm32-wasi` in its own dependencies. Resolving these +// packages relative to `@angular/build` and `@oxc-parser/binding-wasm32-wasi` ensures consistent +// module resolution across package managers. +// +// See: https://github.com/oxc-project/oxc/issues/28717 and https://github.com/napi-rs/napi-rs/issues/3674 +const forceWasi = globalThis.process?.env?.['NAPI_RS_FORCE_WASI']; +if ( + (globalThis.process?.versions?.['webcontainer'] || + forceWasi === 'true' || + forceWasi === 'error') && + !('addonModule' in Object.prototype) +) { + Object.defineProperty(Object.prototype, 'addonModule', { + get() { + return (this as { napiModule?: unknown }).napiModule; + }, + set(value) { + if (this && (typeof this === 'object' || typeof this === 'function')) { + Object.defineProperty(this, 'addonModule', { + value, + writable: true, + configurable: true, + enumerable: true, + }); + } + }, + configurable: true, + enumerable: false, + }); + + const resolvedModules = new Map(); + try { + const wasiBindingPath = require.resolve('@oxc-parser/binding-wasm32-wasi'); + resolvedModules.set('@oxc-parser/binding-wasm32-wasi', wasiBindingPath); + const wasiRequire = createRequire(wasiBindingPath); + resolvedModules.set('@emnapi/core', wasiRequire.resolve('@emnapi/core')); + resolvedModules.set('@emnapi/runtime', wasiRequire.resolve('@emnapi/runtime')); + } catch {} + + if (resolvedModules.size > 0) { + const moduleWithResolve = Module as typeof Module & { + _resolveFilename: ( + request: string, + parent: unknown, + isMain: boolean, + options: unknown, + ) => string; + }; + const origResolveFilename = moduleWithResolve._resolveFilename; + moduleWithResolve._resolveFilename = function (request, parent, isMain, options) { + const resolved = resolvedModules.get(request); + if (resolved !== undefined) { + return resolved; + } + + return origResolveFilename.call(this, request, parent, isMain, options); + }; + } +} diff --git a/packages/angular/build/src/tools/oxc/pure-toplevel-functions_oxc_spec.ts b/packages/angular/build/src/tools/oxc/pure-toplevel-functions_oxc_spec.ts new file mode 100644 index 000000000000..0b91cc98aa45 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/pure-toplevel-functions_oxc_spec.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { transform } from './oxc-transform'; + +function cleanCode(code: string): string { + return code + .replace(/\s+/g, '') + .replace(/"/g, "'") + .replace(/;}/g, '}') + .replace(/,([}\])])/g, '$1'); +} + +function testCase({ + input, + expected, + options, +}: { + input: string; + expected: string; + options?: { topLevelSafeMode: boolean }; +}): jasmine.ImplementationCallback { + return async () => { + const result = transform('test.js', input, { + sourcemap: false, + topLevelSafeMode: options?.topLevelSafeMode, + }); + if (!result?.code) { + fail('Expected oxc-transform to return a transform result.'); + } else { + const actualClean = cleanCode(result.code); + const expectedClean = cleanCode(expected); + expect(actualClean).toEqual(expectedClean); + } + }; +} + +function testCaseNoChange(input: string): jasmine.ImplementationCallback { + return testCase({ input, expected: input }); +} + +describe('pure-toplevel-functions oxc-transform implementation', () => { + it( + 'annotates top-level new expressions', + testCase({ + input: 'var result = new SomeClass();', + expected: 'var result = /*#__PURE__*/ new SomeClass();', + }), + ); + + it( + 'annotates top-level function calls', + testCase({ + input: 'var result = someCall();', + expected: 'var result = /*#__PURE__*/ someCall();', + }), + ); + + it( + 'annotates top-level IIFE assignments with no arguments', + testCase({ + input: 'var SomeClass = (function () { function SomeClass() { } return SomeClass; })();', + expected: + 'var SomeClass = /*#__PURE__*/(function () { function SomeClass() { } return SomeClass; })();', + }), + ); + + it( + 'annotates top-level arrow-function-based IIFE assignments with no arguments', + testCase({ + input: 'var SomeClass = (() => { function SomeClass() { } return SomeClass; })();', + expected: + 'var SomeClass = /*#__PURE__*/(() => { function SomeClass() { } return SomeClass; })();', + }), + ); + + it( + 'does not annotate top-level IIFE assignments with arguments', + testCaseNoChange( + 'var SomeClass = (function () { function SomeClass() { } return SomeClass; })(abc);', + ), + ); + + it( + 'does not annotate top-level arrow-function-based IIFE assignments with arguments', + testCaseNoChange( + 'var SomeClass = (() => { function SomeClass() { } return SomeClass; })(abc);', + ), + ); + + it( + 'does not annotate call expressions inside function declarations', + testCaseNoChange('function funcDecl() { const result = someFunction(); }'), + ); + + it( + 'does not annotate call expressions inside function expressions', + testCaseNoChange('const foo = function funcDecl() { const result = someFunction(); }'), + ); + + it( + 'does not annotate call expressions inside arrow functions', + testCaseNoChange('const foo = () => { const result = someFunction(); }'), + ); + + it( + 'does not annotate new expressions inside function declarations', + testCaseNoChange('function funcDecl() { const result = new SomeClass(); }'), + ); + + it( + 'does not annotate new expressions inside function expressions', + testCaseNoChange('const foo = function funcDecl() { const result = new SomeClass(); }'), + ); + + it( + 'does not annotate new expressions inside arrow functions', + testCaseNoChange('const foo = () => { const result = new SomeClass(); }'), + ); + + it( + 'does not annotate TypeScript helper functions (tslib)', + testCaseNoChange(` + class LanguageState {} + __decorate([ + __metadata("design:type", Function), + __metadata("design:paramtypes", [Object]), + __metadata("design:returntype", void 0) + ], LanguageState.prototype, "checkLanguage", null); + `), + ); + + it( + 'does not annotate _defineProperty function', + testCaseNoChange(` + class LanguageState {} + _defineProperty( + LanguageState, + 'property', + 'value' + ); + `), + ); + + it( + 'does not annotate object literal methods', + testCaseNoChange(` + const literal = { + method() { + var newClazz = new Clazz(); + } + }; + `), + ); + + it( + 'annotates helper functions with non-numeric suffixes', + testCase({ + input: 'var result = __decorate$foo();', + expected: 'var result = /*#__PURE__*/ __decorate$foo();', + }), + ); + + it( + 'does not annotate helper functions with numeric suffixes', + testCaseNoChange('var result = __decorate$1();'), + ); + + describe('topLevelSafeMode: true', () => { + it( + 'annotates top-level `new InjectionToken` expressions', + testCase({ + input: `const result = new InjectionToken('abc');`, + expected: `const result = /*#__PURE__*/ new InjectionToken('abc');`, + options: { topLevelSafeMode: true }, + }), + ); + + it( + 'does not annotate other top-level `new` expressions', + testCase({ + input: 'const result = new SomeClass();', + expected: 'const result = new SomeClass();', + options: { topLevelSafeMode: true }, + }), + ); + + it( + 'does not annotate top-level function calls', + testCase({ + input: 'const result = someCall();', + expected: 'const result = someCall();', + options: { topLevelSafeMode: true }, + }), + ); + }); +}); diff --git a/packages/angular/build/src/tools/oxc/traversal.ts b/packages/angular/build/src/tools/oxc/traversal.ts new file mode 100644 index 000000000000..7d85e312522a --- /dev/null +++ b/packages/angular/build/src/tools/oxc/traversal.ts @@ -0,0 +1,355 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import './oxc-wasi-shim'; +import type { Node } from '@oxc-project/types'; +import { visitorKeys } from 'oxc-parser'; + +/** + * Contextual scope information provided to AST visitor callbacks during traversal. + */ +export interface TraversalContext { + /** + * The current function nesting depth. Top-level code has depth 0. + */ + functionDepth: number; + + /** + * The current class nesting depth. Top-level code has depth 0. + */ + classDepth: number; + + /** + * The immediate enclosing function AST node, if currently inside a function. + */ + parentFunc?: Node; +} + +/** + * A set of TypeScript AST node types that contain executable code or runtime declarations. + */ +const executableTypeScriptNodes = new Set([ + 'TSEnumDeclaration', + 'TSEnumBody', + 'TSEnumMember', + 'TSModuleDeclaration', + 'TSModuleBlock', + 'TSParameterProperty', + 'TSImportEqualsDeclaration', + 'TSExternalModuleReference', +]); + +/** + * Determines whether the given AST node type represents a function declaration or expression. + * + * @param type The AST node type to check. + * @returns True if the type is a function; otherwise, false. + */ +function isFunction(type: Node['type']): boolean { + return ( + type === 'FunctionDeclaration' || + type === 'FunctionExpression' || + type === 'ArrowFunctionExpression' + ); +} + +/** + * Determines whether the given AST node type represents a class declaration or expression. + * + * @param type The AST node type to check. + * @returns True if the type is a class; otherwise, false. + */ +function isClass(type: Node['type']): boolean { + return type === 'ClassDeclaration' || type === 'ClassExpression'; +} + +/** + * Determines whether the given AST node type represents a non-executable TypeScript type-only node. + * Executable TypeScript nodes (e.g. enums, namespaces, parameter properties) return false. + * + * @param type The AST node type to check. + * @returns True if the type is a TypeScript type construct that can be safely pruned; otherwise, false. + */ +function isTypeOnlyTypeScriptNode(type: Node['type']): boolean { + return type.startsWith('TS') && !executableTypeScriptNodes.has(type); +} + +/** + * Pushes an array of AST nodes onto the traversal stack in reverse order. + * + * @param nodes The array of child AST nodes to push. + * @param stack The traversal stack. + */ +function pushNodesReverse( + nodes: readonly (Node | null | undefined)[] | undefined, + stack: (Node | null)[], +): void { + if (nodes) { + for (let i = nodes.length - 1; i >= 0; i--) { + const item = nodes[i]; + if (item) { + stack.push(item); + } + } + } +} + +/** + * Pushes child AST nodes of the specified node onto the traversal stack in reverse order + * so they are visited in source order. + * + * Explicit cases are provided for the most common AST node types (~95%+ of AST nodes) to: + * 1. Fast-path leaf nodes and frequent expressions/statements by avoiding dynamic `visitorKeys` + * dictionary lookups, string indexing, and dynamic array iteration. + * 2. Prune non-executable TypeScript type subtrees (e.g. `typeAnnotation`, `typeParameters`, + * `returnType`, `implements`) so traversal only processes executable JavaScript expressions. + * + * All other less frequent node types fall back to the `visitorKeys` lookup in `default:`. + * + * @param node The parent AST node whose children to push. + * @param stack The traversal stack. + */ +function pushChildNodes(node: Node, stack: (Node | null)[]): void { + switch (node.type) { + // High-frequency leaf nodes (~50% of AST) + case 'Identifier': + case 'PrivateIdentifier': + case 'Literal': + case 'ThisExpression': + break; + + case 'Program': + case 'BlockStatement': + case 'ClassBody': + case 'StaticBlock': + pushNodesReverse(node.body, stack); + break; + + case 'ExpressionStatement': + case 'ParenthesizedExpression': + case 'ChainExpression': + case 'TSNonNullExpression': + case 'TSAsExpression': + case 'TSTypeAssertion': + case 'TSSatisfiesExpression': + case 'TSInstantiationExpression': + case 'TSExportAssignment': + if (node.expression) { + stack.push(node.expression); + } + break; + + case 'CallExpression': + case 'NewExpression': + pushNodesReverse(node.arguments, stack); + if (node.callee) { + stack.push(node.callee); + } + break; + + case 'MemberExpression': + if (node.property) { + stack.push(node.property); + } + if (node.object) { + stack.push(node.object); + } + break; + + case 'BinaryExpression': + case 'LogicalExpression': + case 'AssignmentExpression': + if (node.right) { + stack.push(node.right); + } + if (node.left) { + stack.push(node.left); + } + break; + + case 'UnaryExpression': + case 'UpdateExpression': + case 'AwaitExpression': + case 'YieldExpression': + case 'ReturnStatement': + case 'ThrowStatement': + if (node.argument) { + stack.push(node.argument); + } + break; + + case 'IfStatement': + case 'ConditionalExpression': + if (node.alternate) { + stack.push(node.alternate); + } + if (node.consequent) { + stack.push(node.consequent); + } + if (node.test) { + stack.push(node.test); + } + break; + + case 'VariableDeclaration': + pushNodesReverse(node.declarations, stack); + break; + + case 'VariableDeclarator': + if (node.init) { + stack.push(node.init); + } + if (node.id) { + stack.push(node.id); + } + break; + + case 'FunctionDeclaration': + case 'FunctionExpression': + case 'ArrowFunctionExpression': + if (node.body) { + stack.push(node.body); + } + pushNodesReverse(node.params, stack); + if (node.id) { + stack.push(node.id); + } + break; + + case 'ClassDeclaration': + case 'ClassExpression': + if (node.body) { + stack.push(node.body); + } + if (node.superClass) { + stack.push(node.superClass); + } + if (node.id) { + stack.push(node.id); + } + pushNodesReverse(node.decorators, stack); + break; + + case 'Property': + case 'PropertyDefinition': + case 'MethodDefinition': + if (node.value) { + stack.push(node.value); + } + if (node.key) { + stack.push(node.key); + } + if (node.type !== 'Property') { + pushNodesReverse(node.decorators, stack); + } + break; + + case 'ObjectExpression': + pushNodesReverse(node.properties, stack); + break; + + case 'ArrayExpression': + pushNodesReverse(node.elements, stack); + break; + + case 'TemplateLiteral': + pushNodesReverse(node.expressions, stack); + break; + + case 'TaggedTemplateExpression': + if (node.quasi) { + stack.push(node.quasi); + } + if (node.tag) { + stack.push(node.tag); + } + break; + + default: { + // Prune non-executable TypeScript type-only AST nodes (e.g. TSTypeAnnotation, TSTypeReference, etc.) + if (isTypeOnlyTypeScriptNode(node.type)) { + break; + } + + const keys = visitorKeys[node.type]; + if (!keys) { + break; + } + + for (let i = keys.length - 1; i >= 0; i--) { + const child = ( + node as unknown as Record + )[keys[i]]; + if (!child) { + continue; + } + + if (Array.isArray(child)) { + pushNodesReverse(child, stack); + } else { + stack.push(child); + } + } + break; + } + } +} + +/** + * Traverses ESTree AST nodes in post-order (bottom-up) without recursion. + * + * @param root The root AST node to traverse. + * @param visit Callback invoked on each AST node in post-order. + */ +export function traversePostOrder( + root: Node, + visit: (node: Node, context: TraversalContext) => void, +): void { + const stack: (Node | null)[] = [root]; + let functionDepth = 0; + let classDepth = 0; + const functionStack: Node[] = []; + + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) { + break; + } + if (current === null) { + const node = stack.pop(); + if (!node) { + continue; + } + const type = node.type; + if (isFunction(type)) { + functionDepth--; + functionStack.pop(); + } else if (isClass(type)) { + classDepth--; + } + visit(node, { + functionDepth, + classDepth, + parentFunc: functionStack[functionStack.length - 1], + }); + continue; + } + + stack.push(current); + stack.push(null); + const type = current.type; + if (isFunction(type)) { + functionDepth++; + functionStack.push(current); + } else if (isClass(type)) { + classDepth++; + } + + pushChildNodes(current, stack); + } +} diff --git a/packages/angular/build/src/tools/oxc/types.d.ts b/packages/angular/build/src/tools/oxc/types.d.ts new file mode 100644 index 000000000000..aa1f40580491 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/types.d.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +declare module 'istanbul-lib-instrument' { + export interface Instrumenter { + instrumentSync(code: string, filename: string, inputSourceMap?: object): string; + lastSourceMap(): object | undefined; + } + + export function createInstrumenter(options?: { + produceSourceMap?: boolean; + esModules?: boolean; + coverageVariable?: string; + }): Instrumenter; +} diff --git a/packages/angular/build/src/tools/sass/lexer.ts b/packages/angular/build/src/tools/sass/lexer.ts new file mode 100644 index 000000000000..0fc4f7d1e543 --- /dev/null +++ b/packages/angular/build/src/tools/sass/lexer.ts @@ -0,0 +1,182 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +// TODO: Combine everything into a single pass lexer + +/** + * Determines if a unicode code point is a CSS whitespace character. + * @param code The unicode code point to test. + * @returns true, if the code point is CSS whitespace; false, otherwise. + */ +function isWhitespace(code: number): boolean { + // Based on https://www.w3.org/TR/css-syntax-3/#whitespace + switch (code) { + case 0x0009: // tab + case 0x0020: // space + case 0x000a: // line feed + case 0x000c: // form feed + case 0x000d: // carriage return + return true; + default: + return false; + } +} + +/** + * Scans a CSS or Sass file and locates all valid url function values as defined by the + * syntax specification. + * @param contents A string containing a CSS or Sass file to scan. + * @returns An iterable that yields each CSS url function value found. + */ +export function* findUrls( + contents: string, +): Iterable<{ start: number; end: number; value: string }> { + let pos = 0; + let width = 1; + let current = -1; + const next = () => { + pos += width; + current = contents.codePointAt(pos) ?? -1; + width = current > 0xffff ? 2 : 1; + + return current; + }; + + // Based on https://www.w3.org/TR/css-syntax-3/#consume-ident-like-token + while ((pos = contents.indexOf('url(', pos)) !== -1) { + width = 1; + + // Ensure whitespace, comma, or colon before `url(` + if (pos > 0) { + pos -= 2; + next(); + if (!isWhitespace(current) && current !== 0x002c && current !== 0x003a) { + // Skip - not a url token + pos += 3; + continue; + } + pos += 1; + } + + // Set to position of the ( + pos += 3; + + // Consume all leading whitespace + while (isWhitespace(next())) { + /* empty */ + } + + // Initialize URL state + const url = { start: pos, end: -1, value: '' }; + let complete = false; + + // If " or ', then consume the value as a string + if (current === 0x0022 || current === 0x0027) { + const ending = current; + // Based on https://www.w3.org/TR/css-syntax-3/#consume-string-token + while (!complete) { + switch (next()) { + case -1: // EOF + return; + case 0x000a: // line feed + case 0x000c: // form feed + case 0x000d: // carriage return + // Invalid + complete = true; + break; + case 0x005c: // \ -- character escape + // If not EOF or newline, add the character after the escape + switch (next()) { + case -1: + return; + case 0x000a: // line feed + case 0x000c: // form feed + case 0x000d: // carriage return + // Skip when inside a string + break; + default: + // TODO: Handle hex escape codes + url.value += String.fromCodePoint(current); + break; + } + break; + case ending: + // Full string position should include the quotes for replacement + url.end = pos + 1; + complete = true; + yield url; + break; + default: + url.value += String.fromCodePoint(current); + break; + } + } + + next(); + continue; + } + + // Based on https://www.w3.org/TR/css-syntax-3/#consume-url-token + while (!complete) { + switch (current) { + case -1: // EOF + return; + case 0x0022: // " + case 0x0027: // ' + case 0x0028: // ( + // Invalid + complete = true; + break; + case 0x0029: // ) + // URL is valid and complete + url.end = pos; + complete = true; + break; + case 0x005c: // \ -- character escape + // If not EOF or newline, add the character after the escape + switch (next()) { + case -1: // EOF + return; + case 0x000a: // line feed + case 0x000c: // form feed + case 0x000d: // carriage return + // Invalid + complete = true; + break; + default: + // TODO: Handle hex escape codes + url.value += String.fromCodePoint(current); + break; + } + break; + default: + if (isWhitespace(current)) { + while (isWhitespace(next())) { + /* empty */ + } + // Unescaped whitespace is only valid before the closing ) + if (current === 0x0029) { + // URL is valid + url.end = pos; + } + complete = true; + } else { + // Add the character to the url value + url.value += String.fromCodePoint(current); + } + break; + } + next(); + } + + // An end position indicates a URL was found + if (url.end !== -1) { + yield url; + } + } +} diff --git a/packages/angular/build/src/tools/sass/lexer_spec.ts b/packages/angular/build/src/tools/sass/lexer_spec.ts new file mode 100644 index 000000000000..151077af4c7f --- /dev/null +++ b/packages/angular/build/src/tools/sass/lexer_spec.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { findUrls } from './lexer'; + +describe('Sass URL lexer', () => { + it('should find url immediately after a colon, comma, or whitespace', () => { + const input = `.c{property:url("first"),url("second"), url("third");}`; + + const result = [...findUrls(input)]; + + expect(result).toEqual([ + { start: 16, end: 23, value: 'first' }, + { start: 29, end: 37, value: 'second' }, + { start: 44, end: 51, value: 'third' }, + ]); + }); + + it('should find full url with string containing url function', () => { + const input = `.c{property:url("url('abc')");}`; + + const result = [...findUrls(input)]; + + expect(result).toEqual([{ start: 16, end: 28, value: `url('abc')` }]); + }); +}); diff --git a/packages/angular/build/src/tools/sass/rebasing-importer.ts b/packages/angular/build/src/tools/sass/rebasing-importer.ts new file mode 100644 index 000000000000..ccc5fa3c4664 --- /dev/null +++ b/packages/angular/build/src/tools/sass/rebasing-importer.ts @@ -0,0 +1,396 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { DecodedSourceMap } from '@ampproject/remapping'; +import { MagicString } from 'magic-string'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { basename, dirname, extname, join, relative } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { CanonicalizeContext, Importer, ImporterResult, Syntax } from 'sass-embedded'; +import { assertIsError } from '../../utils/error'; +import { toPosixPath } from '../../utils/path'; +import { findUrls } from './lexer'; + +/** + * A preprocessed cache entry for the files and directories within a previously searched + * directory when performing Sass import resolution. + */ +export interface DirectoryEntry { + files: Set; + directories: Set; +} + +/** + * A Sass Importer base class that provides the load logic to rebase all `url()` functions + * within a stylesheet. The rebasing will ensure that the URLs in the output of the Sass compiler + * reflect the final filesystem location of the output CSS file. + * + * This class provides the core of the rebasing functionality. To ensure that each file is processed + * by this importer's load implementation, the Sass compiler requires the importer's canonicalize + * function to return a non-null value with the resolved location of the requested stylesheet. + * Concrete implementations of this class must provide this canonicalize functionality for rebasing + * to be effective. + */ +abstract class UrlRebasingImporter implements Importer<'sync'> { + /** + * @param entryDirectory The directory of the entry stylesheet that was passed to the Sass compiler. + * @param rebaseSourceMaps When provided, rebased files will have an intermediate sourcemap added to the Map + * which can be used to generate a final sourcemap that contains original sources. + */ + constructor( + private entryDirectory: string, + private rebaseSourceMaps?: Map, + ) { + this.load = this.load.bind(this); + } + + abstract canonicalize(url: string, options: { fromImport: boolean }): URL | null; + + load(canonicalUrl: URL): ImporterResult | null { + const stylesheetPath = fileURLToPath(canonicalUrl); + const stylesheetDirectory = dirname(stylesheetPath); + let contents = readFileSync(stylesheetPath, 'utf-8'); + + // Rebase any URLs that are found + let updatedContents; + for (const { start, end, value } of findUrls(contents)) { + // Skip if value is empty or Webpack-specific prefix + if (value.length === 0 || value[0] === '~' || value[0] === '^') { + continue; + } + + // Skip if root-relative, absolute or protocol relative url + if (/^((?:\w+:)?\/\/|data:|chrome:|\/)/.test(value)) { + continue; + } + + // Skip if a fragment identifier but not a Sass interpolation + if (value[0] === '#' && value[1] !== '{') { + continue; + } + + // Skip if value is value contains a function call + if (/#\{.+\(.+\)\}/.test(value)) { + continue; + } + + // Sass variable usage either starts with a `$` or contains a namespace and a `.$` + const valueNormalized = + value[0] === '$' || /^\w[\w_-]*\.\$/.test(value) ? `#{${value}}` : value; + const rebasedPath = relative(this.entryDirectory, stylesheetDirectory); + + // Normalize path separators and escape characters + // https://developer.mozilla.org/en-US/docs/Web/CSS/url#syntax + const rebasedUrl = toPosixPath(rebasedPath).replace(/[()\s'"]/g, '\\$&'); + + updatedContents ??= new MagicString(contents); + // Always quote the URL to avoid potential downstream parsing problems + updatedContents.update(start, end, `"${rebasedUrl}||file:${valueNormalized}"`); + } + + if (updatedContents) { + contents = updatedContents.toString(); + if (this.rebaseSourceMaps) { + // Generate an intermediate source map for the rebasing changes + const map = updatedContents.generateDecodedMap({ + hires: 'boundary', + includeContent: true, + source: canonicalUrl.href, + }); + this.rebaseSourceMaps.set(canonicalUrl.href, { + ...map, + version: 3, + } satisfies DecodedSourceMap); + } + } + + let syntax: Syntax | undefined; + switch (extname(stylesheetPath).toLowerCase()) { + case '.css': + syntax = 'css'; + break; + case '.sass': + syntax = 'indented'; + break; + default: + syntax = 'scss'; + break; + } + + return { + contents, + syntax, + sourceMapUrl: canonicalUrl, + }; + } +} + +/** + * Provides the Sass importer logic to resolve relative stylesheet imports via both import and use rules + * and also rebase any `url()` function usage within those stylesheets. The rebasing will ensure that + * the URLs in the output of the Sass compiler reflect the final filesystem location of the output CSS file. + */ +export class RelativeUrlRebasingImporter extends UrlRebasingImporter { + constructor( + entryDirectory: string, + private directoryCache = new Map(), + rebaseSourceMaps?: Map, + ) { + super(entryDirectory, rebaseSourceMaps); + this.canonicalize = this.canonicalize.bind(this); + } + + canonicalize(url: string, options: { fromImport: boolean }): URL | null { + return this.resolveImport(url, options.fromImport, true); + } + + /** + * Attempts to resolve a provided URL to a stylesheet file using the Sass compiler's resolution algorithm. + * Based on https://github.com/sass/dart-sass/blob/44d6bb6ac72fe6b93f5bfec371a1fffb18e6b76d/lib/src/importer/utils.dart + * @param url The file protocol URL to resolve. + * @param fromImport If true, URL was from an import rule; otherwise from a use rule. + * @param checkDirectory If true, try checking for a directory with the base name containing an index file. + * @returns A full resolved URL of the stylesheet file or `null` if not found. + */ + private resolveImport(url: string, fromImport: boolean, checkDirectory: boolean): URL | null { + let stylesheetPath; + try { + stylesheetPath = fileURLToPath(url); + } catch { + // Only file protocol URLs are supported by this importer + return null; + } + + const directory = dirname(stylesheetPath); + const extension = extname(stylesheetPath); + const hasStyleExtension = + extension === '.scss' || extension === '.sass' || extension === '.css'; + // Remove the style extension if present to allow adding the `.import` suffix + const filename = basename(stylesheetPath, hasStyleExtension ? extension : undefined); + + const importPotentials = new Set(); + const defaultPotentials = new Set(); + + if (hasStyleExtension) { + if (fromImport) { + importPotentials.add(filename + '.import' + extension); + importPotentials.add('_' + filename + '.import' + extension); + } + defaultPotentials.add(filename + extension); + defaultPotentials.add('_' + filename + extension); + } else { + if (fromImport) { + importPotentials.add(filename + '.import.scss'); + importPotentials.add(filename + '.import.sass'); + importPotentials.add(filename + '.import.css'); + importPotentials.add('_' + filename + '.import.scss'); + importPotentials.add('_' + filename + '.import.sass'); + importPotentials.add('_' + filename + '.import.css'); + } + defaultPotentials.add(filename + '.scss'); + defaultPotentials.add(filename + '.sass'); + defaultPotentials.add(filename + '.css'); + defaultPotentials.add('_' + filename + '.scss'); + defaultPotentials.add('_' + filename + '.sass'); + defaultPotentials.add('_' + filename + '.css'); + } + + let foundDefaults; + let foundImports; + let hasPotentialIndex = false; + + let cachedEntries = this.directoryCache.get(directory); + if (cachedEntries) { + // If there is a preprocessed cache of the directory, perform an intersection of the potentials + // and the directory files. + const { files, directories } = cachedEntries; + foundDefaults = [...defaultPotentials.intersection(files)]; + foundImports = [...importPotentials.intersection(files)]; + hasPotentialIndex = checkDirectory && !hasStyleExtension && directories.has(filename); + } else { + // If no preprocessed cache exists, get the entries from the file system and, while searching, + // generate the cache for later requests. + let entries; + try { + entries = readdirSync(directory, { withFileTypes: true }); + } catch (error) { + assertIsError(error); + // If the containing directory does not exist return null to indicate it cannot be resolved + if (error.code === 'ENOENT') { + return null; + } + + throw new Error(`Error reading directory ["${directory}"] while resolving Sass import`, { + cause: error, + }); + } + + foundDefaults = []; + foundImports = []; + cachedEntries = { files: new Set(), directories: new Set() }; + for (const entry of entries) { + let isDirectory: boolean; + let isFile: boolean; + + if (entry.isSymbolicLink()) { + const stats = statSync(join(directory, entry.name)); + isDirectory = stats.isDirectory(); + isFile = stats.isFile(); + } else { + isDirectory = entry.isDirectory(); + isFile = entry.isFile(); + } + + if (isDirectory) { + cachedEntries.directories.add(entry.name); + + // Record if the name should be checked as a directory with an index file + if (checkDirectory && !hasStyleExtension && entry.name === filename) { + hasPotentialIndex = true; + } + } + + if (!isFile) { + continue; + } + + cachedEntries.files.add(entry.name); + + if (importPotentials.has(entry.name)) { + foundImports.push(entry.name); + } + + if (defaultPotentials.has(entry.name)) { + foundDefaults.push(entry.name); + } + } + + this.directoryCache.set(directory, cachedEntries); + } + + // `foundImports` will only contain elements if `options.fromImport` is true + const result = this.checkFound(foundImports) ?? this.checkFound(foundDefaults); + if (result !== null) { + return pathToFileURL(join(directory, result)); + } + + if (hasPotentialIndex) { + // Check for index files using filename as a directory + return this.resolveImport(url + '/index', fromImport, false); + } + + return null; + } + + /** + * Checks an array of potential stylesheet files to determine if there is a valid + * stylesheet file. More than one discovered file may indicate an error. + * @param found An array of discovered stylesheet files. + * @returns A fully resolved path for a stylesheet file or `null` if not found. + * @throws If there are ambiguous files discovered. + */ + private checkFound(found: string[]): string | null { + if (found.length === 0) { + // Not found + return null; + } + + // More than one found file may be an error + if (found.length > 1) { + // Presence of CSS files alongside a Sass file does not cause an error + const foundWithoutCss = found.filter((element) => extname(element) !== '.css'); + // If the length is zero then there are two or more css files + // If the length is more than one than there are two or more sass/scss files + if (foundWithoutCss.length !== 1) { + throw new Error('Ambiguous import detected.'); + } + + // Return the non-CSS file (sass/scss files have priority) + // https://github.com/sass/dart-sass/blob/44d6bb6ac72fe6b93f5bfec371a1fffb18e6b76d/lib/src/importer/utils.dart#L44-L47 + return foundWithoutCss[0]; + } + + return found[0]; + } +} + +/** + * Provides the Sass importer logic to resolve module (npm package) stylesheet imports asynchronously + * and also rebase any `url()` function usage within those stylesheets. + */ +export class AsyncModuleUrlRebasingImporter implements Importer<'async'> { + private relativeImporter: RelativeUrlRebasingImporter; + + constructor( + entryDirectory: string, + directoryCache: Map, + rebaseSourceMaps: Map | undefined, + private finder: ( + specifier: string, + options: CanonicalizeContext, + ) => Promise | URL | null, + ) { + this.relativeImporter = new RelativeUrlRebasingImporter( + entryDirectory, + directoryCache, + rebaseSourceMaps, + ); + this.canonicalize = this.canonicalize.bind(this); + this.load = this.load.bind(this); + } + + canonicalize(url: string, options: CanonicalizeContext): Promise | URL | null { + if (url.startsWith('file://')) { + return this.relativeImporter.canonicalize(url, options); + } + + // Use `Promise.resolve` instead of an `async` method so the returned promise is an + // instance of the current `globalThis.Promise` (e.g., `ZoneAwarePromise` when Zone.js + // is loaded during SSR). `sass-embedded` checks `result instanceof Promise`, which fails + // for native `async` function return values when `globalThis.Promise` has been replaced. + return Promise.resolve(this.finder(url, options)).then((result) => + result ? this.relativeImporter.canonicalize(result.href, options) : null, + ); + } + + load(canonicalUrl: URL): ImporterResult | null { + return this.relativeImporter.load(canonicalUrl); + } +} + +/** + * Provides the Sass importer logic to resolve load paths located stylesheet imports via both import and + * use rules and also rebase any `url()` function usage within those stylesheets. The rebasing will ensure that + * the URLs in the output of the Sass compiler reflect the final filesystem location of the output CSS file. + */ +export class LoadPathsUrlRebasingImporter extends RelativeUrlRebasingImporter { + constructor( + entryDirectory: string, + directoryCache: Map, + rebaseSourceMaps: Map | undefined, + private loadPaths: Iterable, + ) { + super(entryDirectory, directoryCache, rebaseSourceMaps); + } + + override canonicalize(url: string, options: { fromImport: boolean }): URL | null { + if (url.startsWith('file://')) { + return super.canonicalize(url, options); + } + + let result = null; + for (const loadPath of this.loadPaths) { + result = super.canonicalize(pathToFileURL(join(loadPath, url)).href, options); + if (result !== null) { + break; + } + } + + return result; + } +} diff --git a/packages/angular/build/src/tools/sass/sass-service.ts b/packages/angular/build/src/tools/sass/sass-service.ts new file mode 100644 index 000000000000..8ce65dd7e2f2 --- /dev/null +++ b/packages/angular/build/src/tools/sass/sass-service.ts @@ -0,0 +1,219 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import mergeSourceMaps, { type DecodedSourceMap, type RawSourceMap } from '@ampproject/remapping'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { + AsyncCompiler, + CanonicalizeContext, + CompileResult, + FileImporter, + Importer, + NodePackageImporter, + StringOptions, +} from 'sass-embedded'; +import { useSassEmbedded } from '../../utils/environment-options'; +import { + AsyncModuleUrlRebasingImporter, + DirectoryEntry, + LoadPathsUrlRebasingImporter, + RelativeUrlRebasingImporter, +} from './rebasing-importer'; + +type Importers = + | Importer<'sync'> + | Importer<'async'> + | FileImporter<'sync'> + | FileImporter<'async'> + | NodePackageImporter; + +function isFileImporter(value: Importers): value is FileImporter { + return 'findFileUrl' in value; +} + +/** + * A Sass renderer implementation that uses the persistent Dart Sass embedded compiler + * daemon (`sass-embedded`) communicating over standard input/output with protocol buffers, + * or falls back to the pure-JS Dart Sass async compiler (`sass.initAsyncCompiler()`). + */ +export class SassCompiler { + #asyncCompiler: AsyncCompiler | undefined; + #asyncCompilerPromise: Promise | undefined; + readonly #directoryCache = new Map(); + + constructor(private readonly rebase = false) {} + + async #createAsyncCompiler(): Promise { + if (useSassEmbedded) { + const { initAsyncCompiler } = await import('sass-embedded'); + + return initAsyncCompiler(); + } + + const { initAsyncCompiler } = await import('sass'); + + return initAsyncCompiler() as unknown as Promise; + } + + async #ensureAsyncCompiler(): Promise { + if (this.#asyncCompiler) { + return this.#asyncCompiler; + } + + this.#asyncCompilerPromise ??= this.#createAsyncCompiler(); + + try { + this.#asyncCompiler = await this.#asyncCompilerPromise; + } finally { + this.#asyncCompilerPromise = undefined; + } + + return this.#asyncCompiler; + } + + /** + * Provides information about the Sass implementation. + * This mimics enough of the `sass-embedded` or `sass` value to be used with the `sass-loader`. + */ + get info(): string { + return useSassEmbedded ? 'sass-embedded\tasync-compiler' : 'dart-sass\tasync-compiler'; + } + + /** + * The synchronous render function is not used by the `sass-loader`. + */ + compileString(): never { + throw new Error('Sass compileString is not supported.'); + } + + /** + * Asynchronously request a Sass stylesheet to be rendered using the native embedded compiler + * or the pure-JS async compiler fallback. + * + * @param source The contents to compile. + * @param options The `sass` / `sass-embedded` options to use when rendering the stylesheet. + */ + async compileStringAsync( + source: string, + options: StringOptions<'async'>, + ): Promise { + // The CLI's configuration does not use or expose the ability to define custom Sass functions + if (options.functions && Object.keys(options.functions).length > 0) { + throw new Error('Sass custom functions are not supported.'); + } + + const compiler = await this.#ensureAsyncCompiler(); + + if (!this.rebase) { + return compiler.compileStringAsync(source, options); + } + + const { functions, importers, importer, url, logger, ...serializableOptions } = options; + + let finalImporters: + (Importer<'async'> | FileImporter<'async'> | NodePackageImporter)[] | undefined; + let loadPaths = options.loadPaths; + const entryDirectory = url ? dirname(fileURLToPath(url)) : process.cwd(); + const directoryCache = this.#directoryCache; + const rebaseSourceMaps = options.sourceMap ? new Map() : undefined; + + if (importers?.length) { + if (importers.some((i) => !isFileImporter(i))) { + throw new Error('Only File Importers are supported.'); + } + + finalImporters = [ + new AsyncModuleUrlRebasingImporter( + entryDirectory, + directoryCache, + rebaseSourceMaps, + async (specifier: string, options: CanonicalizeContext): Promise => { + for (const importer of importers) { + const result = await (importer as FileImporter<'async'>).findFileUrl( + specifier, + options, + ); + if (result) { + return result; + } + } + + return null; + }, + ), + ]; + } + + if (loadPaths?.length) { + finalImporters ??= []; + finalImporters.push( + new LoadPathsUrlRebasingImporter( + entryDirectory, + directoryCache, + rebaseSourceMaps, + loadPaths, + ), + ); + loadPaths = undefined; + } + + const relativeImporter = new RelativeUrlRebasingImporter( + entryDirectory, + directoryCache, + rebaseSourceMaps, + ); + + const result = await compiler.compileStringAsync(source, { + ...serializableOptions, + url, + loadPaths, + importers: finalImporters, + importer: relativeImporter, + logger, + }); + + if (result.sourceMap && rebaseSourceMaps?.size) { + result.sourceMap = mergeSourceMaps( + result.sourceMap as unknown as RawSourceMap, + (file, context) => (file !== context.importer ? rebaseSourceMaps.get(file) : null), + ) as unknown as typeof result.sourceMap; + } + + return result; + } + + /** + * Clear the directory cache. + */ + clearCache(): void { + this.#directoryCache.clear(); + } + + /** + * Shutdown the Sass compiler. + * @returns A void promise that resolves when closing is complete. + */ + async close(): Promise { + this.clearCache(); + + if (this.#asyncCompilerPromise) { + try { + await this.#ensureAsyncCompiler(); + } catch { + // Ignore compiler initialization failures on shutdown + } + } + + if (this.#asyncCompiler) { + const compiler = this.#asyncCompiler; + this.#asyncCompiler = undefined; + await compiler.dispose(); + } + } +} diff --git a/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts new file mode 100644 index 000000000000..4af44ce2dd6b --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts @@ -0,0 +1,261 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { lookup as lookupMimeType } from 'mrmime'; +import { readFileSync } from 'node:fs'; +import type { ServerResponse } from 'node:http'; +import { extname } from 'node:path'; +import type { Connect, ViteDevServer } from 'vite' with { + 'resolution-mode': 'import', +}; +import { ResultFile } from '../../../builders/application/results'; +import { calculateHash } from '../../../utils/hash'; +import { AngularMemoryOutputFiles, AngularOutputAssets, pathnameWithoutBasePath } from '../utils'; + +export interface ComponentStyleRecord { + rawContent: Uint8Array; + used?: Set; + reload?: boolean; +} + +const CSS_PREPROCESSOR_REGEXP = /\.(?:s[ac]ss|less|css)$/; +const JS_TS_REGEXP = /\.[cm]?[tj]sx?$/; + +export function createAngularAssetsMiddleware( + server: ViteDevServer, + assets: AngularOutputAssets, + outputFiles: AngularMemoryOutputFiles, + componentStyles: Map, + encapsulateStyle: (style: Uint8Array, componentId: string) => string, +): Connect.NextHandleFunction { + return function angularAssetsMiddleware(req, res, next) { + if (req.url === undefined || res.writableEnded) { + return; + } + + // Parse the incoming request. + // The base of the URL is unused but required to parse the URL. + const pathname = pathnameWithoutBasePath(req.url, server.config.base); + const extension = extname(pathname); + const pathnameHasTrailingSlash = pathname.at(-1) === '/'; + + // Rewrite all build assets to a vite raw fs URL + const asset = assets.get(pathname); + if (asset) { + // This is a workaround to serve extensionless, CSS, JS and TS files without Vite transformations. + if (!extension || JS_TS_REGEXP.test(extension) || CSS_PREPROCESSOR_REGEXP.test(extension)) { + const contents = readFileSync(asset.source); + const etag = `W/"${calculateHash(contents)}"`; + if (checkAndHandleEtag(req, res, etag)) { + return; + } + + const mimeType = lookupMimeType(extension); + if (mimeType) { + res.setHeader('Content-Type', mimeType); + } + + res.setHeader('ETag', etag); + res.setHeader('Cache-Control', 'no-cache'); + res.end(contents); + } else { + // The encoding needs to match what happens in the vite static middleware. + // ref: https://github.com/vitejs/vite/blob/d4f13bd81468961c8c926438e815ab6b1c82735e/packages/vite/src/node/server/middlewares/static.ts#L163 + req.url = `${server.config.base}@fs/${encodeURI(asset.source)}`; + next(); + } + + return; + } + + // HTML fallbacking + // This matches what happens in the vite html fallback middleware. + // ref: https://github.com/vitejs/vite/blob/main/packages/vite/src/node/server/middlewares/htmlFallback.ts#L9 + const htmlAssetSourcePath = pathnameHasTrailingSlash + ? // Trailing slash check for `index.html`. + assets.get(pathname + 'index.html') + : // Non-trailing slash check for fallback `.html` + assets.get(pathname + '.html'); + + if (htmlAssetSourcePath) { + req.url = `${server.config.base}@fs/${encodeURI(htmlAssetSourcePath.source)}`; + next(); + + return; + } + + // Support HTTP HEAD requests for the virtual output files from the Angular build + if (req.method === 'HEAD' && outputFiles.get(pathname)?.servable) { + // While a GET will also generate content, the rest of the response is equivalent + req.method = 'GET'; + } + + // Resource files are handled directly. + // Global stylesheets (CSS files) are currently considered resources to workaround + // dev server sourcemap issues with stylesheets. + if (extension !== '.js' && pathname !== '/index.html' && pathname !== '/index.csr.html') { + const outputFile = outputFiles.get(pathname); + if (outputFile?.servable) { + let data: Uint8Array | string = outputFile.contents; + const componentStyle = componentStyles.get(pathname); + if (componentStyle) { + // Inject component ID for view encapsulation if requested + const searchParams = new URL(req.url, 'http://localhost').searchParams; + const componentId = searchParams.get('ngcomp'); + if (componentId !== null) { + // Track if the component uses ShadowDOM encapsulation (3 = ViewEncapsulation.ShadowDom) + // Shadow DOM components currently require a full reload. + // Vite's CSS hot replacement does not support shadow root searching. + if (searchParams.get('e') === '3') { + componentStyle.reload = true; + } + + // Record the component style usage for HMR updates + if (componentStyle.used === undefined) { + componentStyle.used = new Set([componentId]); + } else { + componentStyle.used.add(componentId); + } + + const etag = `W/"${outputFile.contents.byteLength}-${outputFile.hash}-${componentId}"`; + if (checkAndHandleEtag(req, res, etag)) { + return; + } + + // Shim the stylesheet if a component ID is provided + if (componentId.length > 0) { + // Validate component ID + if (!/^[_.\-\p{Letter}\d]+-c\d+$/u.test(componentId)) { + const message = 'Invalid component stylesheet ID request: ' + componentId; + // eslint-disable-next-line no-console + console.error(message); + res.statusCode = 400; + res.end(message); + + return; + } + + data = encapsulateStyle(data, componentId); + } + + res.setHeader('Content-Type', 'text/css'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('ETag', etag); + res.end(data); + + return; + } + } + + const etag = `W/"${outputFile.contents.byteLength}-${outputFile.hash}"`; + if (checkAndHandleEtag(req, res, etag)) { + return; + } + + const mimeType = lookupMimeType(extension); + if (mimeType) { + res.setHeader('Content-Type', mimeType); + } + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('ETag', etag); + res.end(data); + + return; + } + } + + // If the path has no trailing slash and it matches a servable directory redirect to the same path with slash. + // This matches the default express static behaviour. + // See: https://github.com/expressjs/serve-static/blob/89fc94567fae632718a2157206c52654680e9d01/index.js#L182 + if (!pathnameHasTrailingSlash) { + for (const assetPath of assets.keys()) { + if (pathname === assetPath.substring(0, assetPath.lastIndexOf('/'))) { + const { pathname, search, hash } = new URL(req.url, 'http://localhost'); + const location = [pathname, '/', search, hash].join(''); + + res.statusCode = 301; + res.setHeader('Content-Type', 'text/html'); + res.setHeader('Location', location); + res.end(` + + + + + Redirecting + + +
Redirecting to ${location}
+ + + `); + + return; + } + } + } + + next(); + }; +} + +function checkAndHandleEtag( + req: Connect.IncomingMessage, + res: ServerResponse, + etag: string, +): boolean { + if (req.headers['if-none-match'] === etag) { + res.statusCode = 304; + res.end(); + + return true; + } + + return false; +} + +export function createBuildAssetsMiddleware( + basePath: string, + buildResultFiles: ReadonlyMap, + readHandler: (path: string) => Buffer = readFileSync, +): Connect.NextHandleFunction { + return function buildAssetsMiddleware(req, res, next) { + if (req.url === undefined || res.writableEnded) { + return; + } + + // Parse the incoming request. + // The base of the URL is unused but required to parse the URL. + const pathname = pathnameWithoutBasePath(req.url, basePath); + const extension = extname(pathname); + if (extension && !/\.[mc]?[jt]s(?:\.map)?$/.test(extension)) { + const outputFile = buildResultFiles.get(pathname.slice(1)); + if (outputFile) { + const contents = + outputFile.origin === 'memory' ? outputFile.contents : readHandler(outputFile.source); + + const etag = `W/"${calculateHash(contents)}"`; + if (checkAndHandleEtag(req, res, etag)) { + return; + } + + const mimeType = lookupMimeType(extension); + if (mimeType) { + res.setHeader('Content-Type', mimeType); + } + + res.setHeader('ETag', etag); + res.setHeader('Cache-Control', 'no-cache'); + res.end(contents); + + return; + } + } + + next(); + }; +} diff --git a/packages/angular/build/src/tools/vite/middlewares/base-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/base-middleware.ts new file mode 100644 index 000000000000..a9df78fd2981 --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/base-middleware.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Connect } from 'vite' with { + 'resolution-mode': 'import', +}; +import { addLeadingSlash } from '../../../utils/url'; + +/** + * Patches the Vite base middleware to correctly handle the Angular application's base href. + * This is necessary because Vite's default base middleware might not align with Angular's + * expected path handling when using SSR, especially when a base href is configured. + * + * @param middlewares The Connect server instance containing the middleware stack. + * @param base The base URL path to be handled by the middleware. + */ +export function patchBaseMiddleware(middlewares: Connect.Server, base: string): void { + const entry = middlewares.stack.find( + ({ handle }) => typeof handle === 'function' && handle.name.startsWith('viteBaseMiddleware'), + ); + + if (typeof entry?.handle !== 'function') { + return; + } + + entry.handle = function angularBaseMiddleware( + req: IncomingMessage, + res: ServerResponse, + next: (err?: unknown) => void, + ) { + const url = req.url || '/'; + if (url.startsWith(base)) { + // Rewrite the URL to remove the base prefix before passing it to the next middleware. + // If the URL is exactly the base, it becomes '/'. + // Otherwise, we slice off the base and ensure there's a leading slash. + // See: https://github.com/vitejs/vite/blob/e81c183f8c8ccaf7774ef0d0ee125bf63dbf30b4/packages/vite/src/node/server/middlewares/base.ts#L12 + req.url = url === base ? '/' : addLeadingSlash(url.slice(base.length - 1)); + + return next(); + } + + const { pathname, hash, search } = new URL(url, 'http://localhost'); + if (pathname === '/' || pathname === '/index.html') { + res.writeHead(302, { Location: `${base}${search}${hash}` }); + res.end(); + + return; + } + + next(); + }; +} diff --git a/packages/angular/build/src/tools/vite/middlewares/chrome-devtools-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/chrome-devtools-middleware.ts new file mode 100644 index 000000000000..fdeff2eec705 --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/chrome-devtools-middleware.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { randomUUID } from 'node:crypto'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Connect } from 'vite' with { + 'resolution-mode': 'import', +}; + +type DevToolsJson = { + workspace: { + root: string; + uuid: string; + }; +}; + +const CHROME_DEVTOOLS_ROUTE = '/.well-known/appspecific/com.chrome.devtools.json'; + +export function createChromeDevtoolsMiddleware( + cacheDir: string, + projectRoot: string, +): Connect.NextHandleFunction { + let devtoolsConfig: string | undefined; + const devtoolsConfigPath = join(cacheDir, 'com.chrome.devtools.json'); + + return function chromeDevtoolsMiddleware(req, res, next) { + if (req.url !== CHROME_DEVTOOLS_ROUTE) { + next(); + + return; + } + + if (!devtoolsConfig) { + // We store the UUID and re-use it to ensure Chrome does not repeatedly ask for permissions when restarting the dev server. + try { + devtoolsConfig = readFileSync(devtoolsConfigPath, 'utf-8'); + + const devtoolsConfigJson: DevToolsJson = JSON.parse(devtoolsConfig); + assert.equal(projectRoot, devtoolsConfigJson?.workspace.root); + } catch { + const devtoolsConfigJson: DevToolsJson = { + workspace: { + root: projectRoot, + uuid: randomUUID(), + }, + }; + + devtoolsConfig = JSON.stringify(devtoolsConfigJson, undefined, 2); + + try { + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(devtoolsConfigPath, devtoolsConfig); + } catch {} + } + } + + res.setHeader('Content-Type', 'application/json'); + res.end(devtoolsConfig); + }; +} diff --git a/packages/angular/build/src/tools/vite/middlewares/component-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/component-middleware.ts new file mode 100644 index 000000000000..29360d78af55 --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/component-middleware.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Connect, ViteDevServer } from 'vite' with { + 'resolution-mode': 'import', +}; +import { pathnameWithoutBasePath } from '../utils'; + +const ANGULAR_COMPONENT_PREFIX = '/@ng/component'; + +export function createAngularComponentMiddleware( + server: ViteDevServer, + templateUpdates: ReadonlyMap, +): Connect.NextHandleFunction { + return function angularComponentMiddleware(req, res, next) { + if (req.url === undefined || res.writableEnded) { + return; + } + + const pathname = pathnameWithoutBasePath(req.url, server.config.base); + if (!pathname.includes(ANGULAR_COMPONENT_PREFIX)) { + next(); + + return; + } + + const requestUrl = new URL(req.url, 'http://localhost'); + const componentId = requestUrl.searchParams.get('c'); + if (!componentId) { + res.statusCode = 400; + res.end(); + + return; + } + + const updateCode = templateUpdates.get(encodeURIComponent(componentId)) ?? ''; + + res.setHeader('Content-Type', 'text/javascript'); + res.setHeader('Cache-Control', 'no-cache'); + res.end(updateCode); + }; +} diff --git a/packages/angular/build/src/tools/vite/middlewares/headers-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/headers-middleware.ts new file mode 100644 index 000000000000..c7f9408cec4a --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/headers-middleware.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { ServerResponse } from 'node:http'; +import type { Connect, ViteDevServer } from 'vite' with { + 'resolution-mode': 'import', +}; + +/** + * Creates a middleware for adding custom headers. + * + * This middleware is responsible for setting HTTP headers as configured in the Vite server options. + * If headers are defined in the server configuration, they are applied to the server response. + * + * @param server - The instance of `ViteDevServer` containing the configuration, including custom headers. + * @returns A middleware function that processes the incoming request, sets headers if available, + * and passes control to the next middleware in the chain. + */ +export function createAngularHeadersMiddleware(server: ViteDevServer): Connect.NextHandleFunction { + return function angularHeadersMiddleware( + _req: Connect.IncomingMessage, + res: ServerResponse, + next: Connect.NextFunction, + ) { + const headers = server.config.server.headers; + if (!headers) { + return next(); + } + + for (const [name, value] of Object.entries(headers)) { + if (value !== undefined) { + res.setHeader(name, value); + } + } + + next(); + }; +} diff --git a/packages/angular/build/src/tools/vite/middlewares/host-check-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/host-check-middleware.ts new file mode 100644 index 000000000000..a4d49f4a9eeb --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/host-check-middleware.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Connect } from 'vite' with { + 'resolution-mode': 'import', +}; + +export function patchHostValidationMiddleware(middlewares: Connect.Server): void { + const entry = middlewares.stack.find( + ({ handle }) => + typeof handle === 'function' && handle.name.startsWith('hostValidationMiddleware'), + ); + + if (typeof entry?.handle !== 'function') { + return; + } + + const originalHandle = entry.handle as Connect.NextHandleFunction; + + entry.handle = function angularHostValidationMiddleware( + req: IncomingMessage, + res: ServerResponse, + next: (err?: unknown) => void, + ) { + originalHandle( + req, + { + writeHead: (code) => { + res.writeHead(code, { 'content-type': 'text/html' }); + }, + end: () => { + const hostname = req.headers.host?.toLowerCase().split(':')[0] ?? ''; + res.end(html403(hostname)); + }, + } as ServerResponse, + next, + ); + }; +} + +function html403(hostname: string): string { + return ` + + + + + Blocked request + + + +
+

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

+

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

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

Hello World!

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

hello world!

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

hello world!

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

hello world!

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

hello world!

'); + }); +}); diff --git a/packages/angular/build/src/utils/index-file/nonce.ts b/packages/angular/build/src/utils/index-file/nonce.ts new file mode 100644 index 000000000000..fabb118cd9da --- /dev/null +++ b/packages/angular/build/src/utils/index-file/nonce.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { htmlRewritingStream } from './html-rewriting-stream'; + +/** + * Pattern matching the name of the Angular nonce attribute. Note that this is + * case-insensitive, because HTML attribute names are case-insensitive as well. + */ +const NONCE_ATTR_PATTERN = /ngCspNonce/i; + +const TARGET_LINK_RELS: ReadonlySet = new Set(['stylesheet', 'modulepreload']); + +/** + * Finds the `ngCspNonce` value and copies it to all ` + + + + + + + `); + + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('should add a lowercase nonce expression to style tags', async () => { + const result = await addNonce(` + + + + + + + + + `); + + expect(result).toContain(''); + }); + + it('should preserve any pre-existing nonces', async () => { + const result = await addNonce(` + + + + + + + + + + `); + + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('should use the first nonce that is defined on the page', async () => { + const result = await addNonce(` + + + + + + + + + + `); + + expect(result).toContain(''); + }); + + it('should to all script tags', async () => { + const result = await addNonce(` + + + + + + + + + + + `); + + expect(result).toContain(``); + expect(result).toContain(''); + expect(result).toContain(``); + }); + + it('should add the nonce expression to stylesheet and modulepreload link tags', async () => { + const result = await addNonce(` + + + + + + + + + + + `); + + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('should add the nonce expression when link rel has mixed case or multiple tokens', async () => { + const result = await addNonce(` + + + + + + + + + + `); + + expect(result).toContain(''); + expect(result).toContain(''); + }); +}); diff --git a/packages/angular/build/src/utils/index-file/valid-self-closing-tags.ts b/packages/angular/build/src/utils/index-file/valid-self-closing-tags.ts new file mode 100644 index 000000000000..f86d556b36f0 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/valid-self-closing-tags.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** A list of valid self closing HTML elements */ +export const VALID_SELF_CLOSING_TAGS = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', + + /** SVG tags */ + 'animate', + 'animateMotion', + 'animateTransform', + 'circle', + 'ellipse', + 'feBlend', + 'feColorMatrix', + 'feComponentTransfer', + 'feComposite', + 'feConvolveMatrix', + 'feDiffuseLighting', + 'feDisplacementMap', + 'feDistantLight', + 'feDropShadow', + 'feFlood', + 'feFuncA', + 'feFuncB', + 'feFuncG', + 'feFuncR', + 'feGaussianBlur', + 'feImage', + 'feMerge', + 'feMergeNode', + 'feMorphology', + 'feOffset', + 'fePointLight', + 'feSpecularLighting', + 'feSpotLight', + 'feTile', + 'feTurbulence', + 'line', + 'path', + 'polygon', + 'polyline', + 'rect', + 'text', + 'tspan', + 'linearGradient', + 'radialGradient', + 'stop', + 'image', + 'pattern', + 'defs', + 'g', + 'marker', + 'mask', + 'style', + 'symbol', + 'use', + 'view', + + /** MathML tags */ + 'mspace', + 'mphantom', + 'mrow', + 'mfrac', + 'msqrt', + 'mroot', + 'mstyle', + 'merror', + 'mpadded', + 'mtable', +]); diff --git a/packages/angular/build/src/utils/index.ts b/packages/angular/build/src/utils/index.ts new file mode 100644 index 000000000000..9df32fc10568 --- /dev/null +++ b/packages/angular/build/src/utils/index.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './normalize-asset-patterns'; +export * from './normalize-optimization'; +export * from './normalize-source-maps'; +export * from './load-proxy-config'; +export * from './source-map'; diff --git a/packages/angular/build/src/utils/load-esm.ts b/packages/angular/build/src/utils/load-esm.ts new file mode 100644 index 000000000000..6a6220f66288 --- /dev/null +++ b/packages/angular/build/src/utils/load-esm.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * Lazily compiled dynamic import loader function. + */ +let load: ((modulePath: string | URL) => Promise) | undefined; + +/** + * This uses a dynamic import to load a module which may be ESM. + * CommonJS code can load ESM code via a dynamic import. Unfortunately, TypeScript + * will currently, unconditionally downlevel dynamic import into a require call. + * require calls cannot load ESM code and will result in a runtime error. To workaround + * this, a Function constructor is used to prevent TypeScript from changing the dynamic import. + * Once TypeScript provides support for keeping the dynamic import this workaround can + * be dropped. + * + * @param modulePath The path of the module to load. + * @returns A Promise that resolves to the dynamically imported module. + */ +export function loadEsmModule(modulePath: string | URL): Promise { + load ??= new Function('modulePath', `return import(modulePath);`) as Exclude< + typeof load, + undefined + >; + + return load(modulePath); +} diff --git a/packages/angular/build/src/utils/load-proxy-config.ts b/packages/angular/build/src/utils/load-proxy-config.ts new file mode 100644 index 000000000000..f32db992ac92 --- /dev/null +++ b/packages/angular/build/src/utils/load-proxy-config.ts @@ -0,0 +1,221 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { extname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { makeRe as makeRegExpFromGlob } from 'picomatch'; +import { isDynamicPattern } from 'tinyglobby'; +import { assertIsError } from './error'; +import { loadEsmModule } from './load-esm'; + +export async function loadProxyConfiguration( + root: string, + proxyConfig: string | undefined, +): Promise | undefined> { + if (!proxyConfig) { + return undefined; + } + + const proxyPath = resolve(root, proxyConfig); + + if (!existsSync(proxyPath)) { + throw new Error(`Proxy configuration file ${proxyPath} does not exist.`); + } + + let proxyConfiguration; + switch (extname(proxyPath)) { + case '.json': { + const content = await readFile(proxyPath, 'utf-8'); + + const { parse, printParseErrorCode } = await import('jsonc-parser'); + const parseErrors: import('jsonc-parser').ParseError[] = []; + proxyConfiguration = parse(content, parseErrors, { allowTrailingComma: true }); + + if (parseErrors.length > 0) { + let errorMessage = `Proxy configuration file ${proxyPath} contains parse errors:`; + for (const parseError of parseErrors) { + const { line, column } = getJsonErrorLineColumn(parseError.offset, content); + errorMessage += `\n[${line}, ${column}] ${printParseErrorCode(parseError.error)}`; + } + throw new Error(errorMessage); + } + + break; + } + default: { + try { + proxyConfiguration = await import(proxyPath); + } catch (e) { + assertIsError(e); + if (e.code !== 'ERR_REQUIRE_ASYNC_MODULE') { + throw e; + } + + proxyConfiguration = await loadEsmModule<{ default: unknown }>(pathToFileURL(proxyPath)); + } + + break; + } + } + + if ('default' in proxyConfiguration) { + proxyConfiguration = proxyConfiguration.default; + } + + return normalizeProxyConfiguration(proxyConfiguration); +} + +/** + * Converts glob patterns to regular expressions to support Vite's proxy option. + * Also converts the Webpack supported array form to an object form supported by both. + * + * @param proxy A proxy configuration object. + */ +function normalizeProxyConfiguration( + proxy: Record | object[], +): Record { + let normalizedProxy: Record | undefined; + + if (Array.isArray(proxy)) { + // Construct an object-form proxy configuration from the array + normalizedProxy = {}; + for (const proxyEntry of proxy) { + if (!('context' in proxyEntry)) { + continue; + } + + // Array-form entries contain a context string array with the path(s) + // to use for the configuration entry. A single path string is also + // accepted, as with the Webpack-based development server. + const context = + typeof proxyEntry.context === 'string' ? [proxyEntry.context] : proxyEntry.context; + if (!Array.isArray(context)) { + continue; + } + + delete proxyEntry.context; + for (const contextEntry of context) { + if (typeof contextEntry !== 'string') { + continue; + } + + normalizedProxy[contextEntry] = proxyEntry; + } + } + } else { + normalizedProxy = proxy; + } + + // TODO: Consider upstreaming glob support + // The object is rebuilt so that converted glob entries keep their original position, + // since Vite proxies a request with the first entry that matches it. + const orderedProxy: Record = {}; + for (const [key, value] of Object.entries(normalizedProxy)) { + const context = key[0] !== '^' && isDynamicPattern(key) ? makeUrlRegExpFromGlob(key) : key; + orderedProxy[context] = value; + } + normalizedProxy = orderedProxy; + + // Replace `pathRewrite` field with a `rewrite` function + for (const proxyEntry of Object.values(normalizedProxy)) { + if ( + typeof proxyEntry === 'object' && + 'pathRewrite' in proxyEntry && + proxyEntry.pathRewrite && + typeof proxyEntry.pathRewrite === 'object' + ) { + // Preprocess path rewrite entries + const pathRewriteEntries: [RegExp, string][] = []; + for (const [pattern, value] of Object.entries( + proxyEntry.pathRewrite as Record, + )) { + pathRewriteEntries.push([new RegExp(pattern), value]); + } + + (proxyEntry as Record).rewrite = pathRewriter.bind( + undefined, + pathRewriteEntries, + ); + + delete proxyEntry.pathRewrite; + } + } + + return normalizedProxy; +} + +/** + * Converts a glob pattern to a regular expression source string that matches + * the pathname portion of a request URL while allowing an optional query string. + * + * Vite tests regex proxy keys against `req.url`, which includes the query string, + * whereas `picomatch` generates regexes assuming the input is only a pathname. + */ +function makeUrlRegExpFromGlob(glob: string): string { + const { source } = makeRegExpFromGlob(glob); + + return ( + source + // Ensure picomatch's "at least one character" lookahead does not + // treat the `?` query delimiter as a pathname character (e.g., `/api/*` on `/api/?a=1`). + .replaceAll('(?=.)', '(?=[^?])') + // Prevent single-segment wildcards (`*` and `?`) from matching + // across `?` into the query string (e.g., `/api/*.json` on `/api/users?file=data.json`). + .replaceAll('[^/]', '[^/?]') + // Prevent leading-dot-aware `?` wildcards from matching the `?` delimiter. + .replaceAll('[^.\\/]', '[^.\\/?]') + // Prevent multi-segment globstars (`**`) from matching across `?` into the query string + // (e.g., `/api/**/users` on `/api/other?redirect=/users`). + .replaceAll('\\.).)*?', '\\.)[^?])*?') + // Replace unescaped end-of-input anchors (`$`, both at the end of the pattern and inside + // internal alternation groups for trailing `/**` or negated globs) so an optional query + // string is permitted after the matched pathname. + .replace(/(? offset) { + break; + } + + position = nextNewline + 1; + } + + return { line, column: offset - position + 1 }; +} diff --git a/packages/angular/build/src/utils/load-proxy-config_spec.ts b/packages/angular/build/src/utils/load-proxy-config_spec.ts new file mode 100644 index 000000000000..c44d74e9556b --- /dev/null +++ b/packages/angular/build/src/utils/load-proxy-config_spec.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { loadProxyConfiguration } from './load-proxy-config'; + +describe('loadProxyConfiguration', () => { + let tempDir: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempDir = await mkdtemp(join(baseTmpDir, 'angular-proxy-config-spec-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + async function createGlobMatcher(glob: string): Promise<(url: string) => boolean> { + const configPath = join(tempDir, 'proxy.conf.json'); + await writeFile(configPath, JSON.stringify({ [glob]: { target: 'http://localhost:3000' } })); + const proxy = await loadProxyConfiguration(tempDir, 'proxy.conf.json'); + assert(proxy, 'Expected proxy configuration to be loaded'); + const keys = Object.keys(proxy); + expect(keys.length).toBe(1); + const regex = new RegExp(keys[0]); + + return (url: string) => regex.test(url); + } + + describe('glob to RegExp conversion with query strings', () => { + it('handles trailing globstar (/**) with and without query strings', async () => { + const matches = await createGlobMatcher('/search/**'); + + expect(matches('/search')).toBeTrue(); + expect(matches('/search?q=angular')).toBeTrue(); + expect(matches('/search/')).toBeTrue(); + expect(matches('/search/?q=angular')).toBeTrue(); + expect(matches('/search/items')).toBeTrue(); + expect(matches('/search/items?q=angular')).toBeTrue(); + expect(matches('/search/items?callback=/.hidden')).toBeTrue(); + + expect(matches('/searching')).toBeFalse(); + expect(matches('/searching?q=angular')).toBeFalse(); + expect(matches('/other?redirect=/search')).toBeFalse(); + }); + + it('handles single-segment wildcard (*) without matching across slashes or query strings', async () => { + const matches = await createGlobMatcher('/api/*'); + + expect(matches('/api/users')).toBeTrue(); + expect(matches('/api/users?id=1')).toBeTrue(); + expect(matches('/api/users?redirect=/home/page')).toBeTrue(); + + expect(matches('/api')).toBeFalse(); + expect(matches('/api/')).toBeFalse(); + expect(matches('/api/?a=1')).toBeFalse(); + expect(matches('/api/users/profile')).toBeFalse(); + expect(matches('/api/users/profile?a=1')).toBeFalse(); + }); + + it('handles suffix globs (*.json) without matching query parameters', async () => { + const matches = await createGlobMatcher('/api/*.json'); + + expect(matches('/api/data.json')).toBeTrue(); + expect(matches('/api/data.json?v=1')).toBeTrue(); + + expect(matches('/api/users')).toBeFalse(); + expect(matches('/api/users?file=data.json')).toBeFalse(); + expect(matches('/api/.json?v=1')).toBeFalse(); + }); + + it('handles middle globstar (/**/) without matching paths in query parameters', async () => { + const matches = await createGlobMatcher('/api/**/users'); + + expect(matches('/api/users')).toBeTrue(); + expect(matches('/api/users?page=1')).toBeTrue(); + expect(matches('/api/v1/users?page=1')).toBeTrue(); + expect(matches('/api/v1/v2/users?page=1')).toBeTrue(); + + expect(matches('/api/other?redirect=/users')).toBeFalse(); + expect(matches('/api/users/profile?page=1')).toBeFalse(); + }); + + it('handles single-character wildcard (?) without matching the query delimiter', async () => { + const matches = await createGlobMatcher('/api/v?'); + + expect(matches('/api/v1')).toBeTrue(); + expect(matches('/api/v1?a=1')).toBeTrue(); + + expect(matches('/api/v')).toBeFalse(); + expect(matches('/api/v?')).toBeFalse(); + expect(matches('/api/v?a=1')).toBeFalse(); + expect(matches('/api/v12?a=1')).toBeFalse(); + }); + + it('handles negated globs (!) with and without query strings', async () => { + const matches = await createGlobMatcher('!/api/public/**'); + + expect(matches('/api/public')).toBeFalse(); + expect(matches('/api/public?foo=1')).toBeFalse(); + expect(matches('/api/public/assets?foo=1')).toBeFalse(); + + expect(matches('/api/private')).toBeTrue(); + expect(matches('/api/private?foo=1')).toBeTrue(); + }); + + it('handles brace expansion and extglobs with query strings', async () => { + const matchesBraces = await createGlobMatcher('/api/{auth,users}/**'); + expect(matchesBraces('/api/auth?q=1')).toBeTrue(); + expect(matchesBraces('/api/users/list?q=1')).toBeTrue(); + expect(matchesBraces('/api/other?q=1')).toBeFalse(); + + const matchesExtglob = await createGlobMatcher('/**/!(*.d).ts'); + expect(matchesExtglob('/app/main.ts')).toBeTrue(); + expect(matchesExtglob('/app/main.ts?extra=.d.ts')).toBeTrue(); + expect(matchesExtglob('/app/main.d.ts')).toBeFalse(); + expect(matchesExtglob('/app/main.d.ts?extra=.ts')).toBeFalse(); + }); + }); +}); diff --git a/packages/angular/build/src/utils/load-rolldown.ts b/packages/angular/build/src/utils/load-rolldown.ts new file mode 100644 index 000000000000..78af6aeb3c9d --- /dev/null +++ b/packages/angular/build/src/utils/load-rolldown.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +const MIMALLOC_PURGE_DELAY = 'MIMALLOC_PURGE_DELAY'; + +let rolldownPromise: Promise | undefined; + +/** + * Dynamically loads the `rolldown` module while configuring its embedded + * mimalloc v3 allocator to immediately purge freed arena memory. + * + * Rolldown's native binding statically links mimalloc v3, which defaults to a + * 4-second arena purge delay (`MIMALLOC_PURGE_DELAY=1000` * `MIMALLOC_ARENA_PURGE_MULT=4`) + * and only triggers purges cooperatively during subsequent allocations. In single-run + * CLI builds where Rolldown is not invoked again after bundling completes, non-zero + * purge delays prevent freed arena pages from being decommitted back to the OS, + * leading to high peak RSS during post-bundle stages. + * + * Because mimalloc reads and caches environment options synchronously when the native + * `.node` addon is first loaded (`mi_process_init` -> `_mi_options_init`), temporarily + * setting `MIMALLOC_PURGE_DELAY=0` during the initial `import('rolldown')` call + * configures Rolldown's allocator without leaving the environment variable set for + * subsequent code or child processes. + * + * @returns A promise that resolves to the `rolldown` module namespace. + */ +export function loadRolldown(): Promise { + if (!rolldownPromise) { + const hasCustomPurgeDelay = Object.hasOwn(process.env, MIMALLOC_PURGE_DELAY); + if (!hasCustomPurgeDelay) { + process.env[MIMALLOC_PURGE_DELAY] = '0'; + } + + rolldownPromise = import('rolldown').finally(() => { + if (!hasCustomPurgeDelay) { + delete process.env[MIMALLOC_PURGE_DELAY]; + } + }); + } + + return rolldownPromise; +} diff --git a/packages/angular/build/src/utils/load-rolldown_spec.ts b/packages/angular/build/src/utils/load-rolldown_spec.ts new file mode 100644 index 000000000000..0f7ea7cb4a80 --- /dev/null +++ b/packages/angular/build/src/utils/load-rolldown_spec.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +const MIMALLOC_PURGE_DELAY = 'MIMALLOC_PURGE_DELAY'; + +describe('loadRolldown', () => { + const originalEnvValue = process.env[MIMALLOC_PURGE_DELAY]; + + function getFreshLoadRolldown(): typeof import('./load-rolldown') { + delete require.cache[require.resolve('./load-rolldown')]; + + return require('./load-rolldown'); + } + + afterEach(() => { + if (originalEnvValue !== undefined) { + process.env[MIMALLOC_PURGE_DELAY] = originalEnvValue; + } else { + delete process.env[MIMALLOC_PURGE_DELAY]; + } + delete require.cache[require.resolve('./load-rolldown')]; + }); + + it('loads rolldown and cleans up temporary MIMALLOC_PURGE_DELAY when unset', async () => { + delete process.env[MIMALLOC_PURGE_DELAY]; + const { loadRolldown } = getFreshLoadRolldown(); + + const promise1 = loadRolldown(); + expect(process.env[MIMALLOC_PURGE_DELAY]).toBe('0'); + + const promise2 = loadRolldown(); + expect(promise1).toBe(promise2); + + const rolldownModule = await promise1; + expect(typeof rolldownModule.rolldown).toBe('function'); + expect(Object.hasOwn(process.env, MIMALLOC_PURGE_DELAY)).toBeFalse(); + }); + + it('preserves user-configured MIMALLOC_PURGE_DELAY when already set', async () => { + process.env[MIMALLOC_PURGE_DELAY] = '1000'; + const { loadRolldown } = getFreshLoadRolldown(); + + const promise = loadRolldown(); + expect(process.env[MIMALLOC_PURGE_DELAY]).toBe('1000'); + + const rolldownModule = await promise; + expect(typeof rolldownModule.rolldown).toBe('function'); + expect(process.env[MIMALLOC_PURGE_DELAY]).toBe('1000'); + }); +}); diff --git a/packages/angular/build/src/utils/load-translations.ts b/packages/angular/build/src/utils/load-translations.ts new file mode 100644 index 000000000000..e202273dc73d --- /dev/null +++ b/packages/angular/build/src/utils/load-translations.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Diagnostics } from '@angular/localize/tools'; +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; + +export type TranslationLoader = (path: string) => { + translations: Record; + format: string; + locale?: string; + diagnostics: Diagnostics; + integrity: string; +}; + +export async function createTranslationLoader(): Promise { + const { parsers, diagnostics } = await importParsers(); + + return (path: string) => { + const content = fs.readFileSync(path, 'utf8'); + const unusedParsers = new Map(); + for (const [format, parser] of Object.entries(parsers)) { + const analysis = parser.analyze(path, content); + if (analysis.canParse) { + // Types don't overlap here so we need to use any. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { locale, translations } = parser.parse(path, content, analysis.hint as any); + const integrity = 'sha256-' + createHash('sha256').update(content).digest('base64'); + + return { format, locale, translations, diagnostics, integrity }; + } else { + unusedParsers.set(parser, analysis); + } + } + + const messages: string[] = []; + for (const [parser, analysis] of unusedParsers.entries()) { + messages.push(analysis.diagnostics.formatDiagnostics(`*** ${parser.constructor.name} ***`)); + } + throw new Error( + `Unsupported translation file format in ${path}. The following parsers were tried:\n` + + messages.join('\n'), + ); + }; +} + +async function importParsers() { + try { + // Load ESM `@angular/localize/tools` using the TypeScript dynamic import workaround. + // Once TypeScript provides support for keeping the dynamic import this workaround can be + // changed to a direct dynamic import. + const { + Diagnostics, + ArbTranslationParser, + SimpleJsonTranslationParser, + Xliff1TranslationParser, + Xliff2TranslationParser, + XtbTranslationParser, + } = await import('@angular/localize/tools'); + + const diagnostics = new Diagnostics(); + const parsers = { + arb: new ArbTranslationParser(), + json: new SimpleJsonTranslationParser(), + xlf: new Xliff1TranslationParser(), + xlf2: new Xliff2TranslationParser(), + // The name ('xmb') needs to match the AOT compiler option + xmb: new XtbTranslationParser(), + }; + + return { parsers, diagnostics }; + } catch { + throw new Error( + `Unable to load translation file parsers. Please ensure '@angular/localize' is installed.`, + ); + } +} diff --git a/packages/angular/build/src/utils/normalize-asset-patterns.ts b/packages/angular/build/src/utils/normalize-asset-patterns.ts new file mode 100644 index 000000000000..a95d91829766 --- /dev/null +++ b/packages/angular/build/src/utils/normalize-asset-patterns.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { statSync } from 'node:fs'; +import path from 'node:path'; +import type { AssetPattern, AssetPatternClass } from '../builders/application/schema'; +import { isSubDirectory } from './path'; + +/** + * Asset pattern configuration normalized into its object form with a resolved `output` directory. + */ +export interface NormalizedAssetsPattern extends AssetPatternClass { + /** Relative output directory within the build output path. */ + output: string; +} + +/** + * Normalizes raw string and object asset patterns into {@link NormalizedAssetsPattern} objects + * and validates that all asset inputs and outputs stay within the workspace and output directories. + * + * @param assetPatterns Raw string or object asset patterns from builder options. + * @param workspaceRoot Workspace root directory path. + * @param projectRoot Project root directory path. + * @param projectSourceRoot Optional project source root directory path. + * @returns An array of normalized asset pattern configurations. + */ +export function normalizeAssetPatterns( + assetPatterns: AssetPattern[], + workspaceRoot: string, + projectRoot: string, + projectSourceRoot: string | undefined, +): NormalizedAssetsPattern[] { + if (assetPatterns.length === 0) { + return []; + } + + // When sourceRoot is not available, we default to ${projectRoot}/src. + const sourceRoot = projectSourceRoot || path.join(projectRoot, 'src'); + const resolvedWorkspaceRoot = path.resolve(workspaceRoot); + const resolvedSourceRoot = path.resolve(resolvedWorkspaceRoot, sourceRoot); + const resolvedProjectRoot = path.resolve(resolvedWorkspaceRoot, projectRoot); + + return assetPatterns.map((assetPattern) => { + let normalized: NormalizedAssetsPattern; + + // Normalize string asset patterns to objects. + if (typeof assetPattern === 'string') { + const assetPath = path.normalize(assetPattern); + const resolvedAssetPath = path.resolve(resolvedWorkspaceRoot, assetPath); + let root: string; + + // Check if the string asset is within sourceRoot. + if (isSubDirectory(resolvedSourceRoot, resolvedAssetPath)) { + root = resolvedSourceRoot; + } else if (isSubDirectory(resolvedProjectRoot, resolvedAssetPath)) { + root = resolvedProjectRoot; + } else if (isSubDirectory(resolvedWorkspaceRoot, resolvedAssetPath)) { + root = resolvedWorkspaceRoot; + } else { + throw new Error(`The ${assetPattern} asset path must be within the workspace root.`); + } + + let isDirectory = true; + try { + isDirectory = statSync(resolvedAssetPath, { throwIfNoEntry: false })?.isDirectory() ?? true; + } catch {} + const glob = isDirectory ? '**/*' : path.basename(assetPath); + const input = isDirectory ? assetPath : path.dirname(assetPath); + + // Output directory for both is the relative path from the root to input. + const output = path.relative(root, path.resolve(resolvedWorkspaceRoot, input)); + + normalized = { glob, input, output }; + } else { + if (!isSubDirectory(resolvedWorkspaceRoot, assetPattern.input)) { + throw new Error(`The ${assetPattern.input} asset path must be within the workspace root.`); + } + + normalized = { + ...assetPattern, + output: path.join('.', assetPattern.output ?? ''), + }; + } + + if (normalized.output.startsWith('..')) { + throw new Error('An asset cannot be written to a location outside of the output path.'); + } + + return normalized; + }); +} diff --git a/packages/angular/build/src/utils/normalize-asset-patterns_spec.ts b/packages/angular/build/src/utils/normalize-asset-patterns_spec.ts new file mode 100644 index 000000000000..d83a74fe0ee8 --- /dev/null +++ b/packages/angular/build/src/utils/normalize-asset-patterns_spec.ts @@ -0,0 +1,228 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import path, { join } from 'node:path'; +import { normalizeAssetPatterns } from './normalize-asset-patterns'; +import { resolveAssets } from './resolve-assets'; + +describe('normalizeAssetPatterns', () => { + let tempRoot: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-assets-test-')); + }); + + afterEach(async () => { + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it('should return an empty array when given no asset patterns', () => { + expect(normalizeAssetPatterns([], tempRoot, '', 'src')).toEqual([]); + }); + + it('should normalize a string asset within sourceRoot relative to sourceRoot', async () => { + await mkdir(join(tempRoot, 'src/assets'), { recursive: true }); + await writeFile(join(tempRoot, 'src/assets/logo.png'), 'PNG'); + + const result = normalizeAssetPatterns( + ['src/assets', 'src/assets/logo.png'], + tempRoot, + '', + 'src', + ); + + expect(result).toEqual([ + { glob: '**/*', input: 'src/assets', output: 'assets' }, + { glob: 'logo.png', input: 'src/assets', output: 'assets' }, + ]); + }); + + it('should normalize a string asset in a sibling directory sharing a prefix with sourceRoot (e.g. src-assets)', async () => { + await mkdir(join(tempRoot, 'src-assets'), { recursive: true }); + await writeFile(join(tempRoot, 'src-assets/logo.png'), 'PNG'); + + const result = normalizeAssetPatterns( + ['src-assets', 'src-assets/logo.png'], + tempRoot, + '', + 'src', + ); + + expect(result).toEqual([ + { glob: '**/*', input: 'src-assets', output: 'src-assets' }, + { glob: 'logo.png', input: 'src-assets', output: 'src-assets' }, + ]); + }); + + it('should normalize a library string asset in a sibling directory sharing a prefix with projectSourceRoot', async () => { + await mkdir(join(tempRoot, 'projects/lib/src-assets'), { recursive: true }); + await writeFile(join(tempRoot, 'projects/lib/src-assets/logo.png'), 'PNG'); + + const result = normalizeAssetPatterns( + ['projects/lib/src-assets', 'projects/lib/src-assets/logo.png'], + tempRoot, + 'projects/lib', + 'projects/lib/src', + ); + + expect(result).toEqual([ + { + glob: '**/*', + input: path.normalize('projects/lib/src-assets'), + output: 'src-assets', + }, + { + glob: 'logo.png', + input: path.normalize('projects/lib/src-assets'), + output: 'src-assets', + }, + ]); + }); + + it('should normalize a string asset in a sibling directory sharing a prefix with projectRoot relative to workspaceRoot', async () => { + await mkdir(join(tempRoot, 'projects/lib-shared'), { recursive: true }); + await writeFile(join(tempRoot, 'projects/lib-shared/logo.png'), 'PNG'); + + const result = normalizeAssetPatterns( + ['projects/lib-shared/logo.png'], + tempRoot, + 'projects/lib', + 'projects/lib/src', + ); + + expect(result).toEqual([ + { + glob: 'logo.png', + input: path.normalize('projects/lib-shared'), + output: path.normalize('projects/lib-shared'), + }, + ]); + }); + + it('should throw when a string asset is in a sibling directory sharing a prefix with workspaceRoot', () => { + const siblingDir = `../${path.basename(tempRoot)}-outside/logo.png`; + + expect(() => normalizeAssetPatterns([siblingDir], tempRoot, '', 'src')).toThrowError( + /asset path must be within the workspace root/, + ); + }); +}); + +describe('resolveAssets', () => { + let tempRoot: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-resolve-assets-test-')); + }); + + afterEach(async () => { + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it('should ignore both root and nested .gitkeep files by default', async () => { + await mkdir(join(tempRoot, 'src/assets/nested/deep'), { recursive: true }); + await writeFile(join(tempRoot, 'src/assets/.gitkeep'), ''); + await writeFile(join(tempRoot, 'src/assets/nested/.gitkeep'), ''); + await writeFile(join(tempRoot, 'src/assets/nested/deep/.gitkeep'), ''); + await writeFile(join(tempRoot, 'src/assets/nested/deep/logo.png'), 'PNG'); + + const resolved = await resolveAssets( + [{ glob: '**/*', input: 'src/assets', output: 'assets' }], + tempRoot, + ); + + expect(resolved).toEqual([ + { + source: join(tempRoot, 'src/assets/nested/deep/logo.png'), + destination: join('assets', 'nested/deep/logo.png'), + }, + ]); + }); + + it('should throw when a resolved destination escapes the output root via ".." in glob', async () => { + await mkdir(join(tempRoot, 'src'), { recursive: true }); + await writeFile(join(tempRoot, 'outside.txt'), 'secret'); + + await expectAsync( + resolveAssets([{ glob: '../outside.txt', input: 'src', output: '.' }], tempRoot), + ).toBeRejectedWithError('An asset cannot be written to a location outside of the output path.'); + + await expectAsync( + resolveAssets([{ glob: '../*.txt', input: 'src', output: '.' }], tempRoot), + ).toBeRejectedWithError('An asset cannot be written to a location outside of the output path.'); + }); + + it('should not expand directory patterns in glob whether or not ignore is present', async () => { + await mkdir(join(tempRoot, 'src/assets/sub'), { recursive: true }); + await writeFile(join(tempRoot, 'src/assets/sub/file.txt'), 'content'); + await writeFile(join(tempRoot, 'src/assets/sub/ignored.txt'), 'ignored'); + await writeFile(join(tempRoot, 'src/assets/root.txt'), 'root'); + + const resolved = await resolveAssets( + [ + { glob: 'sub/', input: 'src/assets', output: 'assets' }, + { glob: 'sub/', input: 'src/assets', output: 'assets', ignore: ['ignored.txt'] }, + { glob: 'sub', input: 'src/assets', output: 'assets' }, + { glob: 'sub', input: 'src/assets', output: 'assets', ignore: ['ignored.txt'] }, + { glob: 'root.txt/', input: 'src/assets', output: 'assets' }, + { glob: 'root.txt/', input: 'src/assets', output: 'assets', ignore: ['ignored.txt'] }, + ], + tempRoot, + ); + + expect(resolved).toHaveSize(0); + }); + + it('should expand directory patterns in ignore while keeping non-ignored files', async () => { + await mkdir(join(tempRoot, 'src/assets/sub-a/nested'), { recursive: true }); + await mkdir(join(tempRoot, 'src/assets/sub-b/nested'), { recursive: true }); + await writeFile(join(tempRoot, 'src/assets/sub-a/nested/a.txt'), 'a'); + await writeFile(join(tempRoot, 'src/assets/sub-b/nested/b.txt'), 'b'); + await writeFile(join(tempRoot, 'src/assets/keep.txt'), 'keep'); + + const resolved = await resolveAssets( + [{ glob: '**/*', input: 'src/assets', output: 'assets', ignore: ['sub-a/', 'sub-b'] }], + tempRoot, + ); + + expect(resolved).toEqual([ + { + source: join(tempRoot, 'src/assets/keep.txt'), + destination: join('assets', 'keep.txt'), + }, + ]); + }); + + it('should ignore default ignored files even when specified as a static file glob without ignore', async () => { + await mkdir(join(tempRoot, 'src/assets'), { recursive: true }); + await writeFile(join(tempRoot, 'src/assets/.gitkeep'), ''); + await writeFile(join(tempRoot, 'src/assets/.DS_Store'), ''); + await writeFile(join(tempRoot, 'src/assets/Thumbs.db'), ''); + + const resolved = await resolveAssets( + [ + { glob: '.gitkeep', input: 'src/assets', output: 'assets' }, + { glob: '.DS_Store', input: 'src/assets', output: 'assets' }, + { glob: 'Thumbs.db', input: 'src/assets', output: 'assets' }, + ], + tempRoot, + ); + + expect(resolved).toHaveSize(0); + }); +}); diff --git a/packages/angular/build/src/utils/normalize-cache.ts b/packages/angular/build/src/utils/normalize-cache.ts new file mode 100644 index 000000000000..bb24f774f79b --- /dev/null +++ b/packages/angular/build/src/utils/normalize-cache.ts @@ -0,0 +1,152 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; + +/** Version placeholder is replaced during the build process with actual package version */ +const VERSION = '0.0.0-PLACEHOLDER'; + +export interface NormalizedCachedOptions { + /** Whether disk cache is enabled. */ + enabled: boolean; + + /** Disk cache path. Example: `/.angular/cache/v12.0.0`. */ + path: string; + + /** Disk cache base path. Example: `/.angular/cache`. */ + basePath: string; + + /** + * Workspace-local disk cache path. Example: `/.angular/cache/v12.0.0`. + * Always resolves relative to the current workspace root, even within a Git worktree. + */ + localPath?: string; + + /** + * Workspace-local disk cache base path. Example: `/.angular/cache`. + * Always resolves relative to the current workspace root, even within a Git worktree. + */ + localBasePath?: string; +} + +interface CacheMetadata { + enabled?: boolean; + environment?: 'local' | 'ci' | 'all'; + path?: string; +} + +function hasCacheMetadata(value: unknown): value is { cli: { cache: CacheMetadata } } { + return ( + !!value && + typeof value === 'object' && + 'cli' in value && + !!value['cli'] && + typeof value['cli'] === 'object' && + 'cache' in value['cli'] + ); +} + +function getCacheBasePath(workspaceRoot: string, cachePathSetting: string): string { + if (isAbsolute(cachePathSetting)) { + return cachePathSetting; + } + + try { + // Find the git directory, walking up from workspaceRoot if necessary + let currentDir = workspaceRoot; + while (true) { + const gitPath = join(currentDir, '.git'); + if (existsSync(gitPath)) { + const stat = statSync(gitPath); + if (stat.isFile()) { + // Could be a git worktree (or submodule) + const content = readFileSync(gitPath, 'utf8'); + const match = /^gitdir:\s*(.+)$/m.exec(content); + if (match) { + const gitdir = resolve(currentDir, match[1].trim()); + const commondirPath = join(gitdir, 'commondir'); + if (existsSync(commondirPath)) { + // It's a git worktree + const commondir = readFileSync(commondirPath, 'utf8').trim(); + const commonGitDir = resolve(gitdir, commondir); + const relativeWorkspacePath = relative(currentDir, workspaceRoot); + const mainWorkspaceRoot = resolve(dirname(commonGitDir), relativeWorkspacePath); + if (existsSync(mainWorkspaceRoot)) { + return resolve(mainWorkspaceRoot, cachePathSetting); + } + } + } + } + + break; + } + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + } catch {} + + return resolve(workspaceRoot, cachePathSetting); +} + +/** + * Normalizes the persistent disk cache configuration for a project or workspace. + * + * Resolves whether disk caching is enabled based on the CLI cache metadata, current runtime + * environment (local vs. CI, or WebContainers), and computes the versioned cache directory paths + * (resolving shared Git worktree paths when applicable). + * + * @param projectMetadata The project or workspace metadata object containing optional `cli.cache` settings. + * @param workspaceRoot The absolute path to the workspace root directory. + * @param projectName Optional name of the project used to scope the resolved cache path. + * @param builderName Optional name of the builder or tool used to further scope the resolved cache path. + * @returns The normalized disk cache options including enabled state and resolved directory paths. + */ +export function normalizeCacheOptions( + projectMetadata: unknown, + workspaceRoot: string, + projectName = '', + builderName = '', +): NormalizedCachedOptions { + const cacheMetadata = hasCacheMetadata(projectMetadata) ? projectMetadata.cli.cache : {}; + + const { + // Webcontainers do not currently benefit from persistent disk caching and can lead to increased browser memory usage + enabled = !process.versions.webcontainer, + environment = 'local', + path = '.angular/cache', + } = cacheMetadata; + const isCI = process.env['CI'] === '1' || process.env['CI']?.toLowerCase() === 'true'; + + let cacheEnabled = enabled; + if (cacheEnabled) { + switch (environment) { + case 'ci': + cacheEnabled = isCI; + break; + case 'local': + cacheEnabled = !isCI; + break; + } + } + + const cacheBasePath = getCacheBasePath(workspaceRoot, path); + const localCacheBasePath = isAbsolute(path) ? path : resolve(workspaceRoot, path); + const normalizedBuilderName = builderName.replace('@', '').replaceAll(/[:/]/g, '-'); + + return { + enabled: cacheEnabled, + basePath: cacheBasePath, + path: join(cacheBasePath, VERSION, projectName, normalizedBuilderName), + localBasePath: localCacheBasePath, + localPath: join(localCacheBasePath, VERSION, projectName, normalizedBuilderName), + }; +} diff --git a/packages/angular/build/src/utils/normalize-cache_spec.ts b/packages/angular/build/src/utils/normalize-cache_spec.ts new file mode 100644 index 000000000000..1a0520c96a96 --- /dev/null +++ b/packages/angular/build/src/utils/normalize-cache_spec.ts @@ -0,0 +1,292 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { normalizeCacheOptions } from './normalize-cache'; + +describe('normalizeCacheOptions', () => { + let tempDir: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempDir = await mkdtemp(join(baseTmpDir, 'angular-cache-spec-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('should resolve cache path relative to workspace root in a standard repository', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(join(workspaceRoot, '.git'), { recursive: true }); + + const options = normalizeCacheOptions({}, workspaceRoot); + + expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localBasePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(workspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + }); + + it('should resolve cache path relative to main repository root in a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + // Create main repo structure + await mkdir(mainGitDir, { recursive: true }); + + // Create worktree folder and .git file pointing to the main repo's worktree metadata folder + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + + // Create the commondir file in the worktree metadata folder pointing back to the main .git dir + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions({}, worktreeRoot); + + expect(options.basePath).toBe(resolve(mainRepoRoot, '.angular/cache')); + expect(options.path).toBe(resolve(mainRepoRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + expect(options.localBasePath).toBe(resolve(worktreeRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(worktreeRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + }); + + it('should resolve cache path relative to corresponding nested workspace in main repository for a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const mainWorkspaceRoot = join(mainRepoRoot, 'Site1/ClientApp'); + const worktreeRoot = join(tempDir, 'worktree'); + const worktreeWorkspaceRoot = join(worktreeRoot, 'Site1/ClientApp'); + + // Create main repo with a nested Angular workspace directory + await mkdir(mainGitDir, { recursive: true }); + await mkdir(mainWorkspaceRoot, { recursive: true }); + + // Create worktree with the same nested Angular workspace structure and a .git file at the worktree root + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeWorkspaceRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + + // Point the worktree metadata back to the main .git directory + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions({}, worktreeWorkspaceRoot); + + expect(options.basePath).toBe(resolve(mainWorkspaceRoot, '.angular/cache')); + expect(options.path).toBe(resolve(mainWorkspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + expect(options.localBasePath).toBe(resolve(worktreeWorkspaceRoot, '.angular/cache')); + expect(options.localPath).toBe( + resolve(worktreeWorkspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER'), + ); + }); + + it('should fall back to worktree workspace root when nested workspace does not exist in main repository', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + const worktreeWorkspaceRoot = join(worktreeRoot, 'NewSite/ClientApp'); + + // Create main repo without the 'NewSite/ClientApp' subdirectory (e.g., added only on the worktree branch) + await mkdir(mainGitDir, { recursive: true }); + + // Create worktree with the new nested Angular workspace and link it to the main repo's .git directory + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeWorkspaceRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions({}, worktreeWorkspaceRoot); + + expect(options.basePath).toBe(resolve(worktreeWorkspaceRoot, '.angular/cache')); + expect(options.path).toBe( + resolve(worktreeWorkspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER'), + ); + expect(options.localBasePath).toBe(resolve(worktreeWorkspaceRoot, '.angular/cache')); + expect(options.localPath).toBe( + resolve(worktreeWorkspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER'), + ); + }); + + it('should resolve local cache path relative to worktree root with custom relative path', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + await mkdir(mainGitDir, { recursive: true }); + + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions( + { cli: { cache: { path: 'custom-cache' } } }, + worktreeRoot, + ); + + expect(options.basePath).toBe(resolve(mainRepoRoot, 'custom-cache')); + expect(options.path).toBe(resolve(mainRepoRoot, 'custom-cache', '0.0.0-PLACEHOLDER')); + expect(options.localBasePath).toBe(resolve(worktreeRoot, 'custom-cache')); + expect(options.localPath).toBe(resolve(worktreeRoot, 'custom-cache', '0.0.0-PLACEHOLDER')); + }); + + it('should preserve absolute cache path for both shared and local paths', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + const absoluteCachePath = join(tempDir, 'absolute-cache'); + + await mkdir(mainGitDir, { recursive: true }); + + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions( + { cli: { cache: { path: absoluteCachePath } } }, + worktreeRoot, + ); + + expect(options.basePath).toBe(absoluteCachePath); + expect(options.path).toBe(resolve(absoluteCachePath, '0.0.0-PLACEHOLDER')); + expect(options.localBasePath).toBe(absoluteCachePath); + expect(options.localPath).toBe(resolve(absoluteCachePath, '0.0.0-PLACEHOLDER')); + }); + + it('should resolve cache path relative to workspace root in a git submodule', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const submoduleRoot = join(mainRepoRoot, 'submodule'); + + // Create main repo structure and submodule metadata folder + const submoduleGitDir = join(mainRepoRoot, '.git/modules/sub'); + await mkdir(submoduleGitDir, { recursive: true }); + await mkdir(submoduleRoot, { recursive: true }); + + // Create .git file in submodule pointing to the metadata folder + await writeFile(join(submoduleRoot, '.git'), `gitdir: ../.git/modules/sub`); + + // Submodules do NOT have a 'commondir' file. + const options = normalizeCacheOptions({}, submoduleRoot); + + expect(options.basePath).toBe(resolve(submoduleRoot, '.angular/cache')); + expect(options.localBasePath).toBe(resolve(submoduleRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(submoduleRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + }); + + it('should resolve cache path relative to workspace root when there is no git repository', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(workspaceRoot, { recursive: true }); + + const options = normalizeCacheOptions({}, workspaceRoot); + + expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localBasePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(workspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + }); + + it('should scope path and localPath to projectName when provided', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(join(workspaceRoot, '.git'), { recursive: true }); + + const options = normalizeCacheOptions({}, workspaceRoot, 'my-app'); + + expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.path).toBe(resolve(workspaceRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app')); + expect(options.localBasePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localPath).toBe( + resolve(workspaceRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app'), + ); + }); + + it('should scope path and localPath to projectName in a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + await mkdir(mainGitDir, { recursive: true }); + + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions({}, worktreeRoot, 'my-lib'); + + expect(options.basePath).toBe(resolve(mainRepoRoot, '.angular/cache')); + expect(options.path).toBe(resolve(mainRepoRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-lib')); + expect(options.localBasePath).toBe(resolve(worktreeRoot, '.angular/cache')); + expect(options.localPath).toBe( + resolve(worktreeRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-lib'), + ); + }); + + it('should scope path and localPath to projectName and builderName when provided', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(join(workspaceRoot, '.git'), { recursive: true }); + + const options = normalizeCacheOptions({}, workspaceRoot, 'my-app', 'vite'); + + expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.path).toBe( + resolve(workspaceRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app/vite'), + ); + expect(options.localBasePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localPath).toBe( + resolve(workspaceRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app/vite'), + ); + }); + + it('should scope path and localPath to projectName and builderName in a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + await mkdir(mainGitDir, { recursive: true }); + + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions({}, worktreeRoot, 'my-app', 'vite'); + + expect(options.basePath).toBe(resolve(mainRepoRoot, '.angular/cache')); + expect(options.path).toBe( + resolve(mainRepoRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app/vite'), + ); + expect(options.localBasePath).toBe(resolve(worktreeRoot, '.angular/cache')); + expect(options.localPath).toBe( + resolve(worktreeRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app/vite'), + ); + }); + + it('should normalize builderName to avoid invalid path characters', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(join(workspaceRoot, '.git'), { recursive: true }); + + const options = normalizeCacheOptions({}, workspaceRoot, 'my-app', '@angular/build:unit-test'); + + expect(options.path).toBe( + resolve(workspaceRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app/angular-build-unit-test'), + ); + expect(options.localPath).toBe( + resolve(workspaceRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app/angular-build-unit-test'), + ); + }); +}); diff --git a/packages/angular/build/src/utils/normalize-optimization.ts b/packages/angular/build/src/utils/normalize-optimization.ts new file mode 100644 index 000000000000..fcd5b556f27f --- /dev/null +++ b/packages/angular/build/src/utils/normalize-optimization.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + FontsClass, + OptimizationClass, + OptimizationUnion, + StylesClass, +} from '../builders/application/schema'; + +export type NormalizedOptimizationOptions = Required< + Omit +> & { + fonts: FontsClass; + styles: StylesClass; +}; + +export function normalizeOptimization( + optimization: OptimizationUnion = true, +): NormalizedOptimizationOptions { + if (typeof optimization === 'object') { + const styleOptimization = !!optimization.styles; + + return { + scripts: !!optimization.scripts, + styles: + typeof optimization.styles === 'object' + ? optimization.styles + : { + minify: styleOptimization, + removeSpecialComments: styleOptimization, + inlineCritical: styleOptimization, + }, + fonts: + typeof optimization.fonts === 'object' + ? optimization.fonts + : { + inline: !!optimization.fonts, + }, + }; + } + + return { + scripts: optimization, + styles: { + minify: optimization, + inlineCritical: optimization, + removeSpecialComments: optimization, + }, + fonts: { + inline: optimization, + }, + }; +} diff --git a/packages/angular/build/src/utils/normalize-source-maps.ts b/packages/angular/build/src/utils/normalize-source-maps.ts new file mode 100644 index 000000000000..cf26ca236bae --- /dev/null +++ b/packages/angular/build/src/utils/normalize-source-maps.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { SourceMapClass, SourceMapUnion } from '../builders/application/schema'; + +export function normalizeSourceMaps(sourceMap: SourceMapUnion): SourceMapClass { + const scripts = typeof sourceMap === 'object' ? sourceMap.scripts : sourceMap; + const styles = typeof sourceMap === 'object' ? sourceMap.styles : sourceMap; + const hidden = (typeof sourceMap === 'object' && sourceMap.hidden) || false; + const vendor = (typeof sourceMap === 'object' && sourceMap.vendor) || false; + const sourcesContent = typeof sourceMap === 'object' ? sourceMap.sourcesContent : sourceMap; + + return { + vendor, + hidden, + scripts, + styles, + sourcesContent, + }; +} diff --git a/packages/angular/build/src/utils/path.ts b/packages/angular/build/src/utils/path.ts new file mode 100644 index 000000000000..a236878d134e --- /dev/null +++ b/packages/angular/build/src/utils/path.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { isAbsolute, posix, relative, resolve } from 'node:path'; +import { platform } from 'node:process'; + +const WINDOWS_PATH_SEPERATOR_REGEXP = /\\/g; + +/** + * Converts a Windows-style file path to a POSIX-compliant path. + * + * This function replaces all backslashes (`\`) with forward slashes (`/`). + * It is a no-op on POSIX systems (e.g., Linux, macOS), as the conversion + * only runs on Windows (`win32`). + * + * @param path - The file path to convert. + * @returns The POSIX-compliant file path. + * + * @example + * ```ts + * // On a Windows system: + * toPosixPath('C:\\Users\\Test\\file.txt'); + * // => 'C:/Users/Test/file.txt' + * + * // On a POSIX system (Linux/macOS): + * toPosixPath('/home/user/file.txt'); + * // => '/home/user/file.txt' + * ``` + */ +export function toPosixPath(path: string): string { + return platform === 'win32' ? path.replace(WINDOWS_PATH_SEPERATOR_REGEXP, posix.sep) : path; +} + +/** + * Determines if a path is a subdirectory or file within a parent directory. + * + * @param parent - The parent directory path. + * @param child - The child path to check. + * @returns `true` if the child path is within the parent directory, `false` otherwise. + */ +export function isSubDirectory(parent: string, child: string): boolean { + const resolvedParent = resolve(parent); + const resolvedChild = resolve(parent, child); + const relativePath = toPosixPath(relative(resolvedParent, resolvedChild)); + + return relativePath !== '..' && !relativePath.startsWith('../') && !isAbsolute(relativePath); +} + +/** + * Canonicalizes a file path by normalising Windows drive-letter casing to uppercase. + * + * @param pathString - The file path to canonicalize. + * @returns The canonicalized file path. + */ +export function canonicalizePath(pathString: string): string { + if (platform === 'win32' && /^[a-z]:/.test(pathString)) { + return pathString[0].toUpperCase() + pathString.slice(1); + } + + return pathString; +} diff --git a/packages/angular/build/src/utils/path_spec.ts b/packages/angular/build/src/utils/path_spec.ts new file mode 100644 index 000000000000..cd8ca3372433 --- /dev/null +++ b/packages/angular/build/src/utils/path_spec.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { canonicalizePath, isSubDirectory } from './path'; + +describe('isSubDirectory', () => { + it('should return true for a direct child', () => { + expect(isSubDirectory('/foo/bar', '/foo/bar/baz')).toBeTrue(); + }); + + it('should return true for a nested child', () => { + expect(isSubDirectory('/foo/bar', '/foo/bar/baz/qux/file.txt')).toBeTrue(); + }); + + it('should return true if paths are identical', () => { + expect(isSubDirectory('/foo/bar', '/foo/bar')).toBeTrue(); + }); + + it('should return false for a sibling directory starting with same prefix', () => { + expect(isSubDirectory('/foo/bar', '/foo/bar-outside')).toBeFalse(); + expect(isSubDirectory('/foo/bar', '/foo/bar-outside/file.txt')).toBeFalse(); + }); + + it('should return false for directory outside parent via traversal', () => { + expect(isSubDirectory('/foo/bar', '/foo/bar/../outside')).toBeFalse(); + }); + + it('should return false for parent directory', () => { + expect(isSubDirectory('/foo/bar', '/foo')).toBeFalse(); + }); + + it('should return true for children containing directory names starting with double dots', () => { + expect(isSubDirectory('/foo/bar', '/foo/bar/..baz')).toBeTrue(); + expect(isSubDirectory('/foo/bar', '/foo/bar/..baz/qux')).toBeTrue(); + }); +}); + +describe('canonicalizePath', () => { + it('should return the path unmodified on POSIX systems', () => { + expect(canonicalizePath('/foo/bar/baz')).toBe('/foo/bar/baz'); + }); + + if (process.platform === 'win32') { + it('should uppercase Windows drive-letter casing', () => { + expect(canonicalizePath('c:/foo/bar')).toBe('C:/foo/bar'); + expect(canonicalizePath('d:\\foo\\bar')).toBe('D:\\foo\\bar'); + expect(canonicalizePath('C:/foo/bar')).toBe('C:/foo/bar'); + }); + } +}); diff --git a/packages/angular/build/src/utils/postcss-configuration.ts b/packages/angular/build/src/utils/postcss-configuration.ts new file mode 100644 index 000000000000..e5a4076232a0 --- /dev/null +++ b/packages/angular/build/src/utils/postcss-configuration.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readFile, readdir } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { join, relative } from 'node:path'; + +export interface PostcssConfiguration { + plugins: [name: string, options?: object | string][]; +} + +interface RawPostcssConfiguration { + plugins?: Record | (string | [string, object])[]; +} + +const postcssConfigurationFiles: string[] = ['postcss.config.json', '.postcssrc.json']; +const tailwindConfigFiles: string[] = [ + 'tailwind.config.js', + 'tailwind.config.cjs', + 'tailwind.config.mjs', + 'tailwind.config.ts', +]; + +export interface SearchDirectory { + root: string; + files: Set; +} + +export function generateSearchDirectories(roots: string[]): Promise { + return Promise.all( + roots.map((root) => + readdir(root, { withFileTypes: true }).then((entries) => ({ + root, + files: new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name)), + })), + ), + ); +} + +function findFile( + searchDirectories: SearchDirectory[], + potentialFiles: string[], +): string | undefined { + for (const { root, files } of searchDirectories) { + for (const potential of potentialFiles) { + if (files.has(potential)) { + return join(root, potential); + } + } + } + + return undefined; +} + +export function findTailwindConfiguration( + searchDirectories: SearchDirectory[], +): string | undefined { + return findFile(searchDirectories, tailwindConfigFiles); +} + +export function getTailwindConfig( + searchDirectories: SearchDirectory[], + workspaceRoot: string, + logger?: { warn(message: string): void }, +): { file: string; package: string } | undefined { + const tailwindConfigurationPath = findTailwindConfiguration(searchDirectories); + if (!tailwindConfigurationPath) { + return undefined; + } + + // Create a node resolver from the configuration file + const resolver = createRequire(tailwindConfigurationPath); + try { + return { + file: tailwindConfigurationPath, + package: resolver.resolve('tailwindcss'), + }; + } catch { + const relativeTailwindConfigPath = relative(workspaceRoot, tailwindConfigurationPath); + logger?.warn( + `Tailwind CSS configuration file found (${relativeTailwindConfigPath})` + + ` but the 'tailwindcss' package is not installed.` + + ` To enable Tailwind CSS, please install the 'tailwindcss' package.`, + ); + } + + return undefined; +} + +async function readPostcssConfiguration( + configurationFile: string, +): Promise { + const data = await readFile(configurationFile, 'utf-8'); + const config = JSON.parse(data) as RawPostcssConfiguration; + + return config; +} + +export async function loadPostcssConfiguration(searchDirectories: SearchDirectory[]): Promise< + | { + configPath: string; + config: PostcssConfiguration; + } + | undefined +> { + const configPath = findFile(searchDirectories, postcssConfigurationFiles); + if (!configPath) { + return undefined; + } + + const raw = await readPostcssConfiguration(configPath); + + // If no plugins are defined, consider it equivalent to no configuration + if (!raw.plugins || typeof raw.plugins !== 'object') { + return undefined; + } + + // Normalize plugin array form + if (Array.isArray(raw.plugins)) { + if (raw.plugins.length < 1) { + return undefined; + } + + const config: PostcssConfiguration = { plugins: [] }; + for (const element of raw.plugins) { + if (typeof element === 'string') { + config.plugins.push([element]); + } else { + config.plugins.push(element); + } + } + + return { config, configPath }; + } + + // Normalize plugin object map form + const entries = Object.entries(raw.plugins); + if (entries.length < 1) { + return undefined; + } + + const config: PostcssConfiguration = { plugins: [] }; + for (const [name, options] of entries) { + if (!options || (typeof options !== 'object' && typeof options !== 'string')) { + continue; + } + + config.plugins.push([name, options]); + } + + return { config, configPath }; +} diff --git a/packages/angular/build/src/utils/profiling.ts b/packages/angular/build/src/utils/profiling.ts new file mode 100644 index 000000000000..762a739bd318 --- /dev/null +++ b/packages/angular/build/src/utils/profiling.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { debugPerformance } from './environment-options'; + +let cumulativeDurations: Map | undefined; + +export function resetCumulativeDurations(): void { + cumulativeDurations?.clear(); +} + +export function getAndClearCumulativeDurations(): Record | undefined { + if (!cumulativeDurations || cumulativeDurations.size === 0) { + return undefined; + } + + const data = Object.fromEntries(cumulativeDurations); + + cumulativeDurations.clear(); + + return data; +} + +export function mergeCumulativeDurations(data: Record): void { + cumulativeDurations ??= new Map(); + + for (const [name, durations] of Object.entries(data)) { + let existing = cumulativeDurations.get(name); + if (!existing) { + existing = []; + cumulativeDurations.set(name, existing); + } + + existing.push(...durations); + } +} + +export function logCumulativeDurations(): void { + if (!debugPerformance || !cumulativeDurations) { + return; + } + + for (const [name, durations] of cumulativeDurations) { + let total = 0; + let min; + let max; + for (const duration of durations) { + total += duration; + if (min === undefined || duration < min) { + min = duration; + } + if (max === undefined || duration > max) { + max = duration; + } + } + const average = total / durations.length; + // eslint-disable-next-line no-console + console.log( + `DURATION[${name}]: ${total.toFixed(9)}s [count: ${durations.length}; avg: ${average.toFixed( + 9, + )}s; min: ${min?.toFixed(9)}s; max: ${max?.toFixed(9)}s]`, + ); + } +} + +function recordDuration(name: string, startTime: bigint, cumulative?: boolean): void { + const duration = Number(process.hrtime.bigint() - startTime) / 10 ** 9; + if (cumulative) { + cumulativeDurations ??= new Map(); + const durations = cumulativeDurations.get(name) ?? []; + durations.push(duration); + cumulativeDurations.set(name, durations); + } else { + // eslint-disable-next-line no-console + console.log(`DURATION[${name}]: ${duration.toFixed(9)}s`); + } +} + +export async function profileAsync( + name: string, + action: () => Promise, + cumulative?: boolean, +): Promise { + if (!debugPerformance) { + return action(); + } + + const startTime = process.hrtime.bigint(); + try { + return await action(); + } finally { + recordDuration(name, startTime, cumulative); + } +} + +export function profileSync(name: string, action: () => T, cumulative?: boolean): T { + if (!debugPerformance) { + return action(); + } + + const startTime = process.hrtime.bigint(); + try { + return action(); + } finally { + recordDuration(name, startTime, cumulative); + } +} diff --git a/packages/angular/build/src/utils/project-metadata.ts b/packages/angular/build/src/utils/project-metadata.ts new file mode 100644 index 000000000000..31912d5e9905 --- /dev/null +++ b/packages/angular/build/src/utils/project-metadata.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { join } from 'node:path'; + +/** + * Normalize a directory path string. + * Currently only removes a trailing slash if present. + * @param path A path string. + * @returns A normalized path string. + */ +export function normalizeDirectoryPath(path: string): string { + const last = path.at(-1); + if (last === '/' || last === '\\') { + return path.slice(0, -1); + } + + return path; +} + +export function getProjectRootPaths( + workspaceRoot: string, + projectMetadata: { root?: string; sourceRoot?: string }, +) { + const projectRoot = normalizeDirectoryPath(join(workspaceRoot, projectMetadata.root ?? '')); + const rawSourceRoot = projectMetadata.sourceRoot; + const projectSourceRoot = normalizeDirectoryPath( + rawSourceRoot === undefined ? join(projectRoot, 'src') : join(workspaceRoot, rawSourceRoot), + ); + + return { projectRoot, projectSourceRoot }; +} diff --git a/packages/angular/build/src/utils/purge-cache.ts b/packages/angular/build/src/utils/purge-cache.ts new file mode 100644 index 000000000000..545d2dd97bba --- /dev/null +++ b/packages/angular/build/src/utils/purge-cache.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderContext } from '@angular-devkit/architect'; +import { readdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { normalizeCacheOptions } from './normalize-cache'; + +/** Delete stale cache directories used by previous versions of build-angular. */ +export async function purgeStaleBuildCache(context: BuilderContext): Promise { + const projectName = context.target?.project; + if (!projectName) { + return; + } + + const metadata = await context.getProjectMetadata(projectName); + const { basePath, path, localBasePath, localPath, enabled } = normalizeCacheOptions( + metadata, + context.workspaceRoot, + ); + + if (!enabled) { + return; + } + + const basePaths = new Set([basePath]); + if (localBasePath) { + basePaths.add(localBasePath); + } + + for (const base of basePaths) { + let baseEntries; + try { + baseEntries = await readdir(base, { withFileTypes: true }); + } catch { + // No purging possible if base path does not exist or cannot otherwise be accessed + continue; + } + + const currentPath = base === localBasePath ? localPath : path; + if (!currentPath) { + // Avoid purging if current path is unavailable to prevent deleting the active cache + continue; + } + + const entriesToDelete = baseEntries + .filter((d) => d.isDirectory()) + .map((d) => join(base, d.name)) + .filter((cachePath) => cachePath !== currentPath) + .map((stalePath) => rm(stalePath, { force: true, recursive: true, maxRetries: 3 })); + + await Promise.allSettled(entriesToDelete); + } +} diff --git a/packages/angular/build/src/utils/resolve-assets.ts b/packages/angular/build/src/utils/resolve-assets.ts new file mode 100644 index 000000000000..d7990288b603 --- /dev/null +++ b/packages/angular/build/src/utils/resolve-assets.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { lstat, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { glob, isDynamicPattern } from 'tinyglobby'; +import { MAX_CONCURRENT_READS, mapConcurrent } from './concurrency'; +import { isSubDirectory } from './path'; + +const DEFAULT_ASSET_IGNORE_FILES: ReadonlySet = new Set([ + '.gitkeep', + '.DS_Store', + 'Thumbs.db', +]); +const DEFAULT_ASSET_IGNORE_PATTERNS: readonly string[] = Array.from( + DEFAULT_ASSET_IGNORE_FILES, + (file) => `**/${file}`, +); + +/** + * Normalizes an ignore glob pattern to match directory-exclusion semantics by stripping + * trailing slashes, normalizing POSIX segments, and expanding patterns with `/**` + * (returning both `normalized` and `${normalized}/**` for single-wildcard suffixes + * because `picomatch` does not make trailing `/**` optional after `*`). + * + * @param pattern The raw ignore glob pattern string. + * @returns The normalized POSIX glob patterns. + */ +function normalizeIgnorePattern(pattern: string): string[] { + if (!pattern) { + return []; + } + + const posixNormalized = path.posix.normalize(pattern); + const normalized = + posixNormalized.length > 1 && posixNormalized.endsWith('/') + ? posixNormalized.slice(0, -1) + : posixNormalized; + + if (!normalized.endsWith('*')) { + return [`${normalized}/**`]; + } + + if (normalized === '**' || normalized.endsWith('/**')) { + return [normalized]; + } + + return [normalized, `${normalized}/**`]; +} + +/** + * Combines default asset ignore patterns with user-provided ignore patterns and normalizes + * them so directory patterns (such as `'sub'` or `'sub/'`) exclude all nested files. + * + * @param ignore Optional user-provided ignore glob patterns. + * @returns The normalized ignore glob patterns. + */ +export function normalizeAssetIgnorePatterns(ignore?: readonly string[]): string[] { + if (!ignore?.length) { + return [...DEFAULT_ASSET_IGNORE_PATTERNS]; + } + + return [ + ...DEFAULT_ASSET_IGNORE_PATTERNS, + ...ignore.flatMap((pattern) => normalizeIgnorePattern(pattern)), + ]; +} + +export async function resolveAssets( + entries: { + glob: string; + ignore?: string[]; + input: string; + output: string; + flatten?: boolean; + followSymlinks?: boolean; + }[], + root: string, +): Promise<{ source: string; destination: string }[]> { + const resolvedEntries = await mapConcurrent(entries, MAX_CONCURRENT_READS, async (entry) => { + if (!isSubDirectory(root, entry.input)) { + throw new Error(`The ${entry.input} asset path must be within the workspace root.`); + } + + if (!entry.glob || entry.glob.endsWith('/')) { + return []; + } + + const cwd = path.resolve(root, entry.input); + + if (!entry.ignore?.length && !isDynamicPattern(entry.glob)) { + const src = path.join(cwd, entry.glob); + if (isSubDirectory(cwd, src)) { + if (DEFAULT_ASSET_IGNORE_FILES.has(path.basename(src))) { + return []; + } + + try { + const stats = await (entry.followSymlinks ? stat(src) : lstat(src)); + if (stats.isFile()) { + const filePath = entry.flatten ? path.basename(src) : path.relative(cwd, src); + + return [{ source: src, destination: path.join(entry.output, filePath) }]; + } + + return []; + } catch { + // File does not exist or cannot be accessed. + return []; + } + } + } + + const files = await glob(entry.glob, { + cwd, + dot: true, + expandDirectories: false, + ignore: normalizeAssetIgnorePatterns(entry.ignore), + followSymbolicLinks: entry.followSymlinks ?? false, + }); + + return files.map((file) => { + const destination = path.join(entry.output, entry.flatten ? path.basename(file) : file); + if (destination === '..' || destination.startsWith(`..${path.sep}`)) { + throw new Error('An asset cannot be written to a location outside of the output path.'); + } + + return { + source: path.join(cwd, file), + destination, + }; + }); + }); + + return resolvedEntries.flat(); +} diff --git a/packages/angular/build/src/utils/resolve-project.ts b/packages/angular/build/src/utils/resolve-project.ts new file mode 100644 index 000000000000..ad31825683c4 --- /dev/null +++ b/packages/angular/build/src/utils/resolve-project.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { createRequire } from 'node:module'; +import { join } from 'node:path'; + +/** + * Creates a module resolver function that is strictly scoped to the project root. + * This prevents module resolution from leaking the parent module context when executing inside virtual stores (like pnpm). + * + * @param projectRoot The root directory of the project. + * @returns A resolver function that takes a package name/path and returns its resolved path. + */ +export function createProjectResolver(projectRoot: string): (packageName: string) => string { + const projectRequire = createRequire(join(projectRoot, 'package.json')); + + return (packageName: string) => projectRequire.resolve(packageName, { paths: [projectRoot] }); +} diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts new file mode 100644 index 000000000000..6ff4c8f8191c --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** + * @note For some unknown reason, setting `globalThis.ngServerMode = true` does not work when using ESM loader hooks. + */ +const NG_SERVER_MODE_INIT_BYTES = new TextEncoder().encode('var ngServerMode=true;'); +const UTF8_DECODER = new TextDecoder(); + +/** + * Node.js ESM loader to redirect imports to in memory files. + * @see: https://nodejs.org/api/esm.html#loaders for more information about loaders. + */ + +const MEMORY_URL_SCHEME = 'memory://'; + +export interface ESMInMemoryFileLoaderWorkerData { + outputFiles: Record; + workspaceRoot: string; +} + +let memoryVirtualRootUrl: string; +let outputFiles: Record; + +export function initialize(data: ESMInMemoryFileLoaderWorkerData) { + // This path does not actually exist but is used to overlay the in memory files with the + // actual filesystem for resolution purposes. + // A custom URL schema (such as `memory://`) cannot be used for the resolve output because + // the in-memory files may use `import.meta.url` in ways that assume a file URL. + // `createRequire` is one example of this usage. + memoryVirtualRootUrl = pathToFileURL( + join(data.workspaceRoot, `.angular/prerender-root/${randomUUID()}/`), + ).href; + outputFiles = data.outputFiles; +} + +export function resolve( + specifier: string, + context: { parentURL: undefined | string }, + nextResolve: Function, +) { + // In-memory files loaded from external code will contain a memory scheme + if (specifier.startsWith(MEMORY_URL_SCHEME)) { + let memoryUrl; + try { + memoryUrl = new URL(specifier); + } catch { + assert.fail('External code attempted to use malformed memory scheme: ' + specifier); + } + + // Resolve with a URL based from the virtual filesystem root + return { + format: 'module', + shortCircuit: true, + url: new URL(memoryUrl.pathname.slice(1), memoryVirtualRootUrl).href, + }; + } + + // Use next/default resolve if the parent is not from the virtual root + if (!context.parentURL?.startsWith(memoryVirtualRootUrl)) { + return nextResolve(specifier, context); + } + + // Check for `./` and `../` relative specifiers + const isRelative = + specifier[0] === '.' && + (specifier[1] === '/' || (specifier[1] === '.' && specifier[2] === '/')); + + // Relative specifiers from memory file should be based from the parent memory location + if (isRelative) { + let specifierUrl; + try { + specifierUrl = new URL(specifier, context.parentURL); + } catch {} + + if ( + specifierUrl?.href.startsWith(memoryVirtualRootUrl) && + Object.hasOwn(outputFiles, specifierUrl.href.slice(memoryVirtualRootUrl.length)) + ) { + return { + format: 'module', + shortCircuit: true, + url: specifierUrl.href, + }; + } + + assert.fail( + `In-memory ESM relative file should always exist: '${context.parentURL}' --> '${specifier}'`, + ); + } + + // Update the parent URL to allow for module resolution for the workspace. + // This handles bare specifiers (npm packages) and absolute paths. + // Defer to the next hook in the chain, which would be the + // Node.js default resolve if this is the last user-specified loader. + return nextResolve(specifier, { + ...context, + parentURL: new URL('index.js', memoryVirtualRootUrl).href, + }); +} + +export function load(url: string, context: { format?: string | null }, nextLoad: Function) { + const { format } = context; + + // Load the file from memory if the URL is based in the virtual root + if (url.startsWith(memoryVirtualRootUrl)) { + const rawSource = outputFiles[url.slice(memoryVirtualRootUrl.length)]; + assert(rawSource !== undefined, 'Resolved in-memory ESM file should always exist: ' + url); + + const source = typeof rawSource === 'string' ? rawSource : UTF8_DECODER.decode(rawSource); + + // In-memory files have already been transformer during bundling and can be returned directly + return { + format: format ?? 'module', + shortCircuit: true, + source, + }; + } + + // Only module files potentially require transformation. Angular libraries that would + // need linking are ESM only. + if (format === 'module' && isFileProtocol(url)) { + // Check url instead of filePath so the check is robust across Windows and POSIX path separators. + if (!url.includes('/@angular/')) { + return nextLoad(url, context); + } + + const filePath = fileURLToPath(url); + const fileBytes = readFileSync(filePath); + const source = Buffer.concat([NG_SERVER_MODE_INIT_BYTES, fileBytes]); + + return { + format, + shortCircuit: true, + source, + }; + } + + // Let Node.js handle all other URLs. + return nextLoad(url); +} + +function isFileProtocol(url: string): boolean { + return url.startsWith('file://'); +} diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts new file mode 100644 index 000000000000..0b32c2248b10 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts @@ -0,0 +1,275 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { initialize, load, resolve } from './loader-hooks'; +import { createSharedServerFiles } from './utils'; + +describe('esm-in-memory-loader loader-hooks', () => { + const workspaceRoot = '/mock/workspace/root'; + const sharedFiles = createSharedServerFiles({ + 'main.server.mjs': 'export const main = true;', + 'chunk-abc.mjs': 'export const chunk = "abc";', + 'nested/chunk-sub.mjs': 'export const sub = "sub";', + 'utf8.mjs': 'export const text = "🔥 UTF-8 🚀";', + 'empty.mjs': '', + }); + + beforeEach(() => { + initialize({ + workspaceRoot, + outputFiles: sharedFiles, + }); + }); + + describe('resolve', () => { + it('should resolve memory:// URLs into virtual filesystem URLs', () => { + const nextResolve = jasmine.createSpy('nextResolve'); + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const result = resolve(memoryUrl, { parentURL: undefined }, nextResolve); + + expect(nextResolve).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.url).toContain('/.angular/prerender-root/'); + expect(result.url).toContain('/main.server.mjs'); + }); + + it('should fail when memory:// URL is malformed', () => { + const nextResolve = jasmine.createSpy('nextResolve'); + expect(() => { + resolve('memory://::invalid', { parentURL: undefined }, nextResolve); + }).toThrowMatching((err: Error) => + err.message.includes('External code attempted to use malformed memory scheme'), + ); + expect(nextResolve).not.toHaveBeenCalled(); + }); + + it('should resolve relative specifiers within in-memory files', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine.createSpy('nextResolve'); + const result = resolve('./chunk-abc.mjs', { parentURL }, nextResolve); + + expect(nextResolve).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.url).toContain('/chunk-abc.mjs'); + }); + + it('should resolve relative specifiers navigating parent directories within in-memory files', () => { + const memoryUrl = new URL('./nested/chunk-sub.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine.createSpy('nextResolve'); + const result = resolve('../chunk-abc.mjs', { parentURL }, nextResolve); + + expect(nextResolve).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.url).toContain('/chunk-abc.mjs'); + }); + + it('should fail when relative specifier from in-memory file does not exist', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine.createSpy('nextResolve'); + expect(() => { + resolve('./non-existent.mjs', { parentURL }, nextResolve); + }).toThrowMatching((err: Error) => + err.message.includes('In-memory ESM relative file should always exist'), + ); + expect(nextResolve).not.toHaveBeenCalled(); + }); + + it('should rewrite parentURL to index.js in virtual root for bare package specifiers', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine + .createSpy('nextResolve') + .and.returnValue({ url: 'file:///some/node_modules/@angular/core/index.js' }); + const result = resolve('@angular/core', { parentURL }, nextResolve); + + expect(nextResolve).toHaveBeenCalledWith( + '@angular/core', + jasmine.objectContaining({ + parentURL: jasmine.stringMatching(/\/\.angular\/prerender-root\/[^/]+\/index\.js$/), + }), + ); + expect(result.url).toBe('file:///some/node_modules/@angular/core/index.js'); + }); + + it('should delegate to nextResolve for external non-memory URLs', () => { + const nextResolve = jasmine + .createSpy('nextResolve') + .and.returnValue({ url: 'file:///some/ext/pkg' }); + const result = resolve('some-pkg', { parentURL: 'file:///some/ext/file.js' }, nextResolve); + + expect(nextResolve).toHaveBeenCalledWith('some-pkg', { + parentURL: 'file:///some/ext/file.js', + }); + expect(result.url).toBe('file:///some/ext/pkg'); + }); + }); + + describe('load', () => { + it('should load in-memory file source from SharedArrayBuffer backed Uint8Array', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe('export const main = true;'); + }); + + it('should load in-memory file with multi-byte UTF-8 characters', () => { + const memoryUrl = new URL('./utf8.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe('export const text = "🔥 UTF-8 🚀";'); + }); + + it('should load in-memory file with empty content', () => { + const memoryUrl = new URL('./empty.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe(''); + }); + + it('should load in-memory file source with non-zero byteOffset in Uint8Array', () => { + const target = 'export const sliced = 42;'; + const fullBuffer = Buffer.from(`__PADDING__${target}__MORE__`); + const offset = Buffer.byteLength('__PADDING__', 'utf-8'); + const length = Buffer.byteLength(target, 'utf-8'); + const subView = new Uint8Array(fullBuffer.buffer, fullBuffer.byteOffset + offset, length); + + initialize({ + workspaceRoot, + outputFiles: { + 'sliced.mjs': subView, + }, + }); + + const memoryUrl = new URL('./sliced.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe(target); + }); + + it('should load in-memory file source when outputFiles contain string values', () => { + initialize({ + workspaceRoot, + outputFiles: { + 'string-file.mjs': 'export const fromString = 1;', + }, + }); + + const memoryUrl = new URL('./string-file.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe('export const fromString = 1;'); + }); + + it('should throw when in-memory file does not exist in outputFiles', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nonExistentVirtualUrl = rootResolve.url.replace('main.server.mjs', 'missing.mjs'); + const nextLoad = jasmine.createSpy('nextLoad'); + + expect(() => load(nonExistentVirtualUrl, { format: 'module' }, nextLoad)).toThrowError( + /Resolved in-memory ESM file should always exist/, + ); + expect(nextLoad).not.toHaveBeenCalled(); + }); + + it('should prepend ngServerMode initialization for @angular/ file URLs', async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + const tmpDir = await mkdtemp(join(baseTmpDir, 'loader-hooks-test-')); + + try { + const angularDir = join(tmpDir, '@angular/core'); + await mkdir(angularDir, { recursive: true }); + const filePath = join(angularDir, 'index.mjs'); + await writeFile(filePath, 'export const VERSION = "1.0.0";'); + + const nextLoad = jasmine.createSpy('nextLoad'); + const result = load(pathToFileURL(filePath).href, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(new TextDecoder().decode(result.source)).toBe( + 'var ngServerMode=true;export const VERSION = "1.0.0";', + ); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('should delegate to nextLoad for non-angular file URLs', () => { + const nextLoad = jasmine.createSpy('nextLoad').and.returnValue({ format: 'module' }); + const result = load( + 'file:///workspace/node_modules/rxjs/index.js', + { format: 'module' }, + nextLoad, + ); + + expect(nextLoad).toHaveBeenCalledWith('file:///workspace/node_modules/rxjs/index.js', { + format: 'module', + }); + expect(result.format).toBe('module'); + }); + + it('should delegate to nextLoad for non-memory non-file URLs', () => { + const nextLoad = jasmine.createSpy('nextLoad').and.returnValue({ format: 'builtin' }); + const result = load('node:fs', { format: 'builtin' }, nextLoad); + + expect(nextLoad).toHaveBeenCalledWith('node:fs'); + expect(result.format).toBe('builtin'); + }); + }); +}); diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts new file mode 100644 index 000000000000..56747e17bd03 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { registerHooks } from 'node:module'; +import { workerData } from 'node:worker_threads'; +import { initialize, load, resolve } from './loader-hooks'; + +initialize(workerData); +registerHooks({ resolve, load }); diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.ts new file mode 100644 index 000000000000..7eb2bd1f5c49 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const IMPORT_EXEC_ARGV = + '--import=' + pathToFileURL(join(__dirname, 'register-hooks.js')).href; + +/** + * Creates a shared zero-copy `Uint8Array` backed by a `SharedArrayBuffer` for the given file content. + */ +export function createSharedFile(content: string | Uint8Array): Uint8Array { + if (typeof content === 'string') { + const byteLength = Buffer.byteLength(content, 'utf-8'); + const sab = new SharedArrayBuffer(byteLength); + Buffer.from(sab).write(content, 'utf-8'); + + return new Uint8Array(sab); + } + + if (content.buffer instanceof SharedArrayBuffer) { + return content; + } + + const sab = new SharedArrayBuffer(content.byteLength); + const view = new Uint8Array(sab); + view.set(content); + + return view; +} + +/** + * Creates shared zero-copy `Uint8Array` views backed by `SharedArrayBuffer` for all output files. + */ +export function createSharedServerFiles( + outputFiles: Record, +): Record { + const sharedFiles: Record = {}; + + for (const [key, value] of Object.entries(outputFiles)) { + sharedFiles[key] = createSharedFile(value); + } + + return sharedFiles; +} diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils_spec.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils_spec.ts new file mode 100644 index 000000000000..b7bb2844b66e --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils_spec.ts @@ -0,0 +1,147 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { createSharedFile, createSharedServerFiles } from './utils'; + +describe('server-rendering shared file utilities', () => { + describe('createSharedFile', () => { + it('should create a Uint8Array backed by SharedArrayBuffer from string', () => { + const text = 'console.log("hello world");'; + const shared = createSharedFile(text); + + expect(shared instanceof Uint8Array).toBeTrue(); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(Buffer.byteLength(text, 'utf-8')); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(text); + }); + + it('should correctly handle multi-byte UTF-8 characters', () => { + const text = 'export const greeting = "🚀 Привет, мир! 🌍";'; + const shared = createSharedFile(text); + + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(text); + }); + + it('should handle empty string', () => { + const shared = createSharedFile(''); + + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(0); + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(''); + }); + + it('should handle empty Uint8Array', () => { + const shared = createSharedFile(new Uint8Array(0)); + + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(0); + }); + + it('should return the same Uint8Array if already backed by SharedArrayBuffer', () => { + const text = 'export default 42;'; + const shared1 = createSharedFile(text); + const shared2 = createSharedFile(shared1); + + expect(shared2).toBe(shared1); + expect(shared2.buffer).toBe(shared1.buffer); + }); + + it('should return existing Uint8Array view if already backed by SharedArrayBuffer with non-zero byteOffset', () => { + const text = '0123456789abcdef'; + const shared1 = createSharedFile(text); + const subView = new Uint8Array(shared1.buffer, 4, 8); + const shared2 = createSharedFile(subView); + + expect(shared2).toBe(subView); + expect(shared2.byteOffset).toBe(4); + expect(shared2.byteLength).toBe(8); + + const decoded = Buffer.from(shared2.buffer, shared2.byteOffset, shared2.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe('456789ab'); + }); + + it('should convert standard Uint8Array to SharedArrayBuffer backed Uint8Array', () => { + const regularBuffer = Buffer.from('export const test = true;'); + expect(regularBuffer.buffer instanceof SharedArrayBuffer).toBeFalse(); + + const shared = createSharedFile(regularBuffer); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe('export const test = true;'); + }); + + it('should handle Uint8Array slice with non-zero byteOffset from standard ArrayBuffer', () => { + const target = 'export const data = 123;'; + const fullBuffer = Buffer.from(`__PREFIX__${target}__SUFFIX__`); + const offset = Buffer.byteLength('__PREFIX__', 'utf-8'); + const length = Buffer.byteLength(target, 'utf-8'); + const subView = new Uint8Array(fullBuffer.buffer, fullBuffer.byteOffset + offset, length); + + const shared = createSharedFile(subView); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(length); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(target); + }); + }); + + describe('createSharedServerFiles', () => { + it('should convert all entries in a record to SharedArrayBuffer backed Uint8Arrays', () => { + const files: Record = { + 'main.server.mjs': 'export default function() {}', + 'chunk-1.mjs': 'export const x = 1;', + 'render-utils.mjs': Buffer.from('export const helper = () => true;'), + }; + + const sharedFiles = createSharedServerFiles(files); + + expect(Object.keys(sharedFiles)).toEqual([ + 'main.server.mjs', + 'chunk-1.mjs', + 'render-utils.mjs', + ]); + + for (const [key, shared] of Object.entries(sharedFiles)) { + expect(shared instanceof Uint8Array).toBeTrue(); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + + const expectedText = + typeof files[key] === 'string' ? files[key] : (files[key] as Buffer).toString('utf-8'); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(expectedText); + } + }); + + it('should handle empty file map', () => { + const sharedFiles = createSharedServerFiles({}); + expect(Object.keys(sharedFiles).length).toBe(0); + }); + }); +}); diff --git a/packages/angular/build/src/utils/server-rendering/fetch-patch.ts b/packages/angular/build/src/utils/server-rendering/fetch-patch.ts new file mode 100644 index 000000000000..c099d7dd902c --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/fetch-patch.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { lookup as lookupMimeType } from 'mrmime'; +import { readFile } from 'node:fs/promises'; +import { extname } from 'node:path'; +import { workerData } from 'node:worker_threads'; + +/** + * This is passed as workerData when setting up the worker via the `piscina` package. + */ +const { assetFiles } = workerData as { + assetFiles: Record; +}; + +const assetsCache: Map; content: Buffer }> = + new Map(); + +export function patchFetchToLoadInMemoryAssets(baseURL: URL): void { + const originalFetch = globalThis.fetch; + const patchedFetch: typeof fetch = async (input, init) => { + let url: URL; + if (input instanceof URL) { + url = input; + } else if (typeof input === 'string') { + url = new URL(input, baseURL); + } else if (typeof input === 'object' && 'url' in input) { + url = new URL(input.url, baseURL); + } else { + return originalFetch(input, init); + } + + const { hostname } = url; + const pathname = decodeURIComponent(url.pathname); + + if (hostname !== baseURL.hostname || !assetFiles[pathname]) { + // Only handle relative requests or files that are in assets. + return originalFetch(input, init); + } + + const cachedAsset = assetsCache.get(pathname); + if (cachedAsset) { + const { content, headers } = cachedAsset; + + return new Response(content, { + headers, + }); + } + + const extension = extname(pathname); + const mimeType = lookupMimeType(extension); + const content = await readFile(assetFiles[pathname]); + const headers = mimeType + ? { + 'Content-Type': mimeType, + } + : undefined; + + assetsCache.set(pathname, { headers, content }); + + return new Response(content, { + headers, + }); + }; + + globalThis.fetch = patchedFetch; +} diff --git a/packages/angular/build/src/utils/server-rendering/launch-server.ts b/packages/angular/build/src/utils/server-rendering/launch-server.ts new file mode 100644 index 000000000000..95b2784c6f63 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/launch-server.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { createServer } from 'node:http'; +import { loadEsmModuleFromMemory } from './load-esm-from-memory'; +import { isSsrNodeRequestHandler, isSsrRequestHandler } from './utils'; + +export const DEFAULT_URL = new URL('http://ng-localhost/'); + +/** + * Launches a server that handles local requests. + * + * @returns A promise that resolves to the URL of the running server. + */ +export async function launchServer(): Promise { + const { reqHandler } = await loadEsmModuleFromMemory('./server.mjs'); + const { createWebRequestFromNodeRequest, writeResponseToNodeResponse } = (await import( + '@angular/ssr/node' as string + )) as typeof import('@angular/ssr/node', { with: { 'resolution-mode': 'import' } }); + + if (!isSsrNodeRequestHandler(reqHandler) && !isSsrRequestHandler(reqHandler)) { + return DEFAULT_URL; + } + + const server = createServer((req, res) => { + (async () => { + // handle request + if (isSsrNodeRequestHandler(reqHandler)) { + await reqHandler(req, res, (e) => { + throw e ?? new Error(`Unable to handle request: '${req.url}'.`); + }); + } else { + const webRes = await reqHandler(createWebRequestFromNodeRequest(req)); + if (webRes) { + await writeResponseToNodeResponse(webRes, res); + } else { + res.statusCode = 501; + res.end('Not Implemented.'); + } + } + })().catch((e) => { + res.statusCode = 500; + res.end('Internal Server Error.'); + // eslint-disable-next-line no-console + console.error(e); + }); + }); + + server.unref(); + + await new Promise((resolve) => server.listen(0, 'localhost', resolve)); + + const serverAddress = server.address(); + assert(serverAddress, 'Server address should be defined.'); + assert(typeof serverAddress !== 'string', 'Server address should not be a string.'); + + return new URL(`http://localhost:${serverAddress.port}/`); +} diff --git a/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts new file mode 100644 index 000000000000..87ca9928a86f --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { ApplicationRef, Type } from '@angular/core'; +import type { BootstrapContext } from '@angular/platform-browser'; +import type { ɵextractRoutesAndCreateRouteTree, ɵgetOrCreateAngularServerApp } from '@angular/ssr'; +import { assertIsError } from '../error'; +import { loadEsmModule } from '../load-esm'; + +/** + * Represents the exports available from the main server bundle. + */ +interface MainServerBundleExports { + default: ((context: BootstrapContext) => Promise) | Type; + ɵextractRoutesAndCreateRouteTree: typeof ɵextractRoutesAndCreateRouteTree; + ɵgetOrCreateAngularServerApp: typeof ɵgetOrCreateAngularServerApp; +} + +/** + * Represents the exports available from the server bundle. + */ +interface ServerBundleExports { + reqHandler?: unknown; +} + +export function loadEsmModuleFromMemory( + path: './main.server.mjs', +): Promise; +export function loadEsmModuleFromMemory(path: './server.mjs'): Promise; +export function loadEsmModuleFromMemory( + path: './main.server.mjs' | './server.mjs', +): Promise { + return loadEsmModule(new URL(path, 'memory://')).catch((e) => { + assertIsError(e); + + // While the error is an 'instanceof Error', it is extended with non transferable properties + // and cannot be transferred from a worker when using `--import`. This results in the error object + // displaying as '[Object object]' when read outside of the worker. Therefore, we reconstruct the error message here. + const error: Error & { code?: string } = new Error(e.message); + error.stack = e.stack; + error.name = e.name; + error.code = e.code; + + throw error; + }) as Promise; +} diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts new file mode 100644 index 000000000000..6877107a0b96 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -0,0 +1,269 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Metafile } from 'esbuild'; +import { Buffer } from 'node:buffer'; +import { extname } from 'node:path'; +import { + INDEX_HTML_SERVER, + NormalizedApplicationBuildOptions, +} from '../../builders/application/options'; +import { + type BuildOutputFile, + BuildOutputFileType, + createOutputFile, +} from '../../tools/esbuild/bundler-files'; +import { findNonce } from '../index-file/nonce'; +import { joinUrlParts } from '../url'; + +export const SERVER_APP_MANIFEST_FILENAME = 'angular-app-manifest.mjs'; +export const SERVER_APP_ENGINE_MANIFEST_FILENAME = 'angular-app-engine-manifest.mjs'; + +/** + * A set of server-generated dependencies that are treated as external. + * + * These dependencies are marked as external because they are produced by a + * separate bundling process and are not included in the primary bundle. This + * ensures that these generated files are resolved from an external source rather + * than being part of the main bundle. + */ +export const SERVER_GENERATED_EXTERNALS = new Set([ + './polyfills.server.mjs', + './' + SERVER_APP_MANIFEST_FILENAME, + './' + SERVER_APP_ENGINE_MANIFEST_FILENAME, +]); + +const MAIN_SERVER_OUTPUT_FILENAME = 'main.server.mjs'; + +/** + * A mapping of unsafe characters to their escaped Unicode equivalents. + */ +const UNSAFE_CHAR_MAP: Record = { + '`': '\\`', + '$': '\\$', + '\\': '\\\\', +}; + +/** + * Escapes unsafe characters in a given string by replacing them with + * their Unicode escape sequences. + * + * @param str - The string to be escaped. + * @returns The escaped string where unsafe characters are replaced. + */ +function escapeUnsafeChars(str: string): string { + return str.replace(/[$`\\]/g, (c) => UNSAFE_CHAR_MAP[c]); +} + +/** + * Generates the server manifest for the App Engine environment. + * + * This manifest is used to configure the server-side rendering (SSR) setup for the + * Angular application when deployed to Google App Engine. It includes the entry points + * for different locales and the base HREF for the application. + * + * @param i18nOptions - The internationalization options for the application build. This + * includes settings for inlining locales and determining the output structure. + * @param allowedHosts - A list of hosts that are allowed to access the server-side application. + * @param baseHref - The base HREF for the application. This is used to set the base URL + * for all relative URLs in the application. + */ +export function generateAngularServerAppEngineManifest( + i18nOptions: NormalizedApplicationBuildOptions['i18nOptions'], + allowedHosts: string[], + baseHref: string | undefined, +): string { + const entryPoints: Record = {}; + const supportedLocales: Record = {}; + + if (i18nOptions.shouldInline && !i18nOptions.flatOutput) { + for (const locale of i18nOptions.inlineLocales) { + const { subPath } = i18nOptions.locales[locale]; + const importPath = `${subPath ? `${subPath}/` : ''}${MAIN_SERVER_OUTPUT_FILENAME}`; + entryPoints[subPath] = `() => import(${JSON.stringify(`./${importPath}`)})`; + supportedLocales[locale] = subPath; + } + } else { + entryPoints[''] = `() => import('./${MAIN_SERVER_OUTPUT_FILENAME}')`; + supportedLocales[i18nOptions.sourceLocale] = ''; + } + + // Remove trailing slash but retain leading slash. + let basePath = baseHref || '/'; + if (basePath.length > 1 && basePath.at(-1) === '/') { + basePath = basePath.slice(0, -1); + } + + const manifestContent = ` +export default { + basePath: ${JSON.stringify(basePath)}, + allowedHosts: ${JSON.stringify(allowedHosts, undefined, 2)}, + supportedLocales: ${JSON.stringify(supportedLocales, undefined, 2)}, + entryPoints: { + ${Object.entries(entryPoints) + .map(([key, value]) => `${JSON.stringify(key)}: ${value}`) + .join(',\n ')} + }, +}; +`; + + return manifestContent; +} + +/** + * Generates the server manifest for the standard Node.js environment. + * + * This manifest is used to configure the server-side rendering (SSR) setup for the + * Angular application when running in a standard Node.js environment. It includes + * information about the bootstrap module, whether to inline critical CSS, and any + * additional HTML and CSS output files. + * + * @param additionalHtmlOutputFiles - A map of additional HTML output files generated + * during the build process, keyed by their file paths. + * @param outputFiles - An array of all output files from the build process, including + * JavaScript and CSS files. + * @param inlineCriticalCss - A boolean indicating whether critical CSS should be inlined + * in the server-side rendered pages. + * @param routes - An optional array of route definitions for the application, used for + * server-side rendering and routing. + * @param locale - An optional string representing the locale or language code to be used for + * the application, helping with localization and rendering content specific to the locale. + * @param baseHref - The base HREF for the application. This is used to set the base URL + * for all relative URLs in the application. + * @param initialFiles - A list of initial files that preload tags have already been added for. + * @param metafile - An esbuild metafile object. + * @param publicPath - The configured public path. + * + * @returns An object containing: + * - `manifestContent`: A string of the SSR manifest content. + * - `serverAssetsChunks`: An array of build output files containing the generated assets for the server. + */ +export async function generateAngularServerAppManifest( + additionalHtmlOutputFiles: Map, + outputFiles: BuildOutputFile[], + inlineCriticalCss: boolean, + routes: readonly unknown[] | undefined, + locale: string | undefined, + baseHref: string, + initialFiles: Set, + metafile: Metafile, + publicPath: string | undefined, +): Promise<{ + manifestContent: string; + serverAssetsChunks: BuildOutputFile[]; +}> { + const serverAssetsChunks: BuildOutputFile[] = []; + const serverAssets: Record = {}; + const criticalCssPlans: unknown[] = []; + let nonce: string | undefined; + + // TODO(alanagius): This is done here as we do not use module resolution bundler/node16 + const { compileSheet, encodePlan } = (await import( + 'beasties/compiler' as string + )) as typeof import('beasties/compiler', { with: { 'resolution-mode': 'import' } }); + + for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) { + const extension = extname(file.path); + if (extension === '.html') { + const jsChunkFilePath = `assets-chunks/${file.path.replace(/[./]/g, '_')}.mjs`; + const escapedContent = escapeUnsafeChars(file.text); + + serverAssetsChunks.push( + createOutputFile( + jsChunkFilePath, + `export default \`${escapedContent}\`;`, + BuildOutputFileType.ServerApplication, + ), + ); + + // JavaScript engine script parsers normalize `\r\n` (2 bytes in UTF-8) to `\n` (1 byte in UTF-8) in template literals. + // Subtracting the count of `\r\n` occurrences avoids allocating any temporary strings or match arrays for large assets. + let size = Buffer.byteLength(file.text); + let pos = file.text.indexOf('\r\n'); + while (pos !== -1) { + size--; + pos = file.text.indexOf('\r\n', pos + 2); + } + + // Asset paths are derived from route paths and can contain arbitrary characters, so they are + // serialized rather than interpolated into the generated executable manifest. + serverAssets[file.path] = + `{size: ${size}, hash: ${JSON.stringify(file.hash)}, ` + + `text: () => import(${JSON.stringify(`./${jsChunkFilePath}`)}).then(m => m.default)}`; + } else if (inlineCriticalCss && extension === '.css') { + const sheet = compileSheet(file.text, { + href: joinUrlParts(publicPath ?? '', file.path), + }); + criticalCssPlans.push(encodePlan(sheet)); + } + } + + const indexHtml = additionalHtmlOutputFiles.get(INDEX_HTML_SERVER)?.text; + if (indexHtml) { + nonce = (await findNonce(indexHtml)) ?? undefined; + } + + // When routes have been extracted, mappings are no longer needed, as preloads will be included in the metadata. + const entryPointToBrowserMapping = routes?.length + ? undefined + : generateLazyLoadedFilesMappings(metafile, initialFiles, publicPath); + + const manifestContent = ` +export default { + bootstrap: () => import('./main.server.mjs').then(m => m.default), + baseHref: ${JSON.stringify(baseHref)}, +${criticalCssPlans.length ? ` criticalCssPlans: ${JSON.stringify(criticalCssPlans)},\n` : ''}${ + nonce ? ` nonce: ${JSON.stringify(nonce)},\n` : '' + } locale: ${JSON.stringify(locale)}, + routes: ${JSON.stringify(routes, undefined, 2)}, + entryPointToBrowserMapping: ${JSON.stringify(entryPointToBrowserMapping, undefined, 2)}, + assets: { + ${Object.entries(serverAssets) + .map(([key, value]) => `${JSON.stringify(key)}: ${value}`) + .join(',\n ')} + }, +}; +`; + + return { manifestContent, serverAssetsChunks }; +} + +/** + * Maps entry points to their corresponding browser bundles for lazy loading. + * + * This function processes a metafile's outputs to generate a mapping between browser-side entry points + * and the associated JavaScript files that should be loaded in the browser. It includes the entry-point's + * own path and any valid imports while excluding initial files or external resources. + */ +function generateLazyLoadedFilesMappings( + metafile: Metafile, + initialFiles: Set, + publicPath = '', +): Record { + const entryPointToBundles: Record = {}; + for (const [fileName, { entryPoint, exports, imports }] of Object.entries(metafile.outputs)) { + // Skip files that don't have an entryPoint, no exports, or are not .js + if (!entryPoint || exports?.length < 1 || !fileName.endsWith('.js')) { + continue; + } + + const importedPaths: string[] = [`${publicPath}${fileName}`]; + + for (const { kind, external, path } of imports) { + if (external || initialFiles.has(path) || kind !== 'import-statement') { + continue; + } + + importedPaths.push(`${publicPath}${path}`); + } + + entryPointToBundles[entryPoint] = importedPaths; + } + + return entryPointToBundles; +} diff --git a/packages/angular/build/src/utils/server-rendering/manifest_spec.ts b/packages/angular/build/src/utils/server-rendering/manifest_spec.ts new file mode 100644 index 000000000000..4d9ae0da32f6 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/manifest_spec.ts @@ -0,0 +1,207 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Metafile } from 'esbuild'; +import { BuildOutputFileType, createOutputFile } from '../../tools/esbuild/bundler-files'; +import { initializeHash } from '../hash'; +import { generateAngularServerAppManifest } from './manifest'; + +describe('generateAngularServerAppManifest', () => { + beforeAll(async () => { + await initializeHash(); + }); + + const dummyMetafile = { inputs: {}, outputs: {} } as unknown as Metafile; + + /** + * Evaluates a generated manifest, which both asserts that it is syntactically valid JavaScript and + * gives access to the values it declares. The dynamic imports it contains are never invoked. + */ + function evaluateManifest(manifestContent: string): unknown { + return new Function(manifestContent.replace('export default', 'return'))(); + } + + function generateManifest( + htmlOutputFiles: Record, + baseHref = '/', + ): ReturnType { + const additionalHtmlOutputFiles = new Map( + Object.entries(htmlOutputFiles).map(([path, content]) => [ + path, + createOutputFile(path, content, BuildOutputFileType.Browser), + ]), + ); + + return generateAngularServerAppManifest( + additionalHtmlOutputFiles, + [], + false, + undefined, + undefined, + baseHref, + new Set(), + dummyMetafile, + undefined, + ); + } + + it('should include criticalCssPlans when inlineCriticalCss is true', async () => { + const additionalHtml = new Map([ + [ + 'index.server.html', + createOutputFile( + 'index.server.html', + '', + BuildOutputFileType.ServerApplication, + ), + ], + ]); + const outputFiles = [ + createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser), + ]; + + const { manifestContent } = await generateAngularServerAppManifest( + additionalHtml, + outputFiles, + true, + undefined, + undefined, + '/', + new Set(), + dummyMetafile, + undefined, + ); + + expect(manifestContent).toContain('criticalCssPlans: ['); + expect(manifestContent).toContain('styles.css'); + expect(manifestContent).not.toContain('nonce:'); + }); + + it('should not include criticalCssPlans when inlineCriticalCss is false', async () => { + const additionalHtml = new Map([ + [ + 'index.server.html', + createOutputFile( + 'index.server.html', + '', + BuildOutputFileType.ServerApplication, + ), + ], + ]); + const outputFiles = [ + createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser), + ]; + + const { manifestContent } = await generateAngularServerAppManifest( + additionalHtml, + outputFiles, + false, + undefined, + undefined, + '/', + new Set(), + dummyMetafile, + undefined, + ); + + expect(manifestContent).not.toContain('criticalCssPlans:'); + }); + + it('should extract template nonce from index HTML when present', async () => { + const additionalHtml = new Map([ + [ + 'index.server.html', + createOutputFile( + 'index.server.html', + '', + BuildOutputFileType.ServerApplication, + ), + ], + ]); + const outputFiles = [ + createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser), + ]; + + const { manifestContent } = await generateAngularServerAppManifest( + additionalHtml, + outputFiles, + true, + undefined, + undefined, + '/', + new Set(), + dummyMetafile, + undefined, + ); + + expect(manifestContent).toContain('nonce: "{% nonce %}"'); + }); + + it('should not include css files in serverAssetsChunks or assets', async () => { + const additionalHtml = new Map([ + [ + 'index.server.html', + createOutputFile( + 'index.server.html', + '', + BuildOutputFileType.ServerApplication, + ), + ], + ]); + const outputFiles = [ + createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser), + ]; + + const { manifestContent, serverAssetsChunks } = await generateAngularServerAppManifest( + additionalHtml, + outputFiles, + true, + undefined, + undefined, + '/', + new Set(), + dummyMetafile, + undefined, + ); + + expect(serverAssetsChunks.some((chunk) => chunk.path.includes('styles'))).toBeFalse(); + expect(manifestContent).not.toContain('"styles.css":'); + }); + + it('should serialize asset paths which contain JavaScript string delimiters', async () => { + const assetPath = "catalog/customer's-choice/index.html"; + const { manifestContent } = await generateManifest({ + [assetPath]: '
Featured
', + }); + + expect(evaluateManifest(manifestContent)).toEqual( + jasmine.objectContaining({ + assets: { + [assetPath]: jasmine.objectContaining({ + size: 21, + hash: jasmine.any(String), + text: jasmine.any(Function), + }), + }, + }), + ); + }); + + it('should serialize a base href which contains JavaScript string delimiters', async () => { + const { manifestContent } = await generateManifest( + { 'index.html': '
' }, + "/o'brien/", + ); + + expect(evaluateManifest(manifestContent)).toEqual( + jasmine.objectContaining({ + baseHref: "/o'brien/", + }), + ); + }); +}); diff --git a/packages/angular/build/src/utils/server-rendering/models.ts b/packages/angular/build/src/utils/server-rendering/models.ts new file mode 100644 index 000000000000..9a9020d2db7f --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/models.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { RenderMode, ɵextractRoutesAndCreateRouteTree } from '@angular/ssr'; +import { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; + +type Writeable = T extends readonly (infer U)[] ? U[] : never; + +export interface RoutesExtractorWorkerData extends ESMInMemoryFileLoaderWorkerData { + assetFiles: Record; +} + +export type SerializableRouteTreeNode = ReturnType< + Awaited>['routeTree']['toObject'] +>; + +export type WritableSerializableRouteTreeNode = Writeable; + +export interface RoutersExtractorWorkerResult { + serializedRouteTree: SerializableRouteTreeNode; + appShellRoute?: string; + errors: string[]; +} + +/** + * Local copy of `RenderMode` exported from `@angular/ssr`. + * This constant is needed to handle interop between CommonJS (CJS) and ES Modules (ESM) formats. + * + * It maps `RenderMode` enum values to their corresponding numeric identifiers. + */ +export const RouteRenderMode: Record = { + Server: 0, + Client: 1, + Prerender: 2, +}; diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts new file mode 100644 index 000000000000..ec687be0e011 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -0,0 +1,477 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readFile } from 'node:fs/promises'; +import { extname, posix } from 'node:path'; +import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import { OutputMode, Format as PrerenderFormat } from '../../builders/application/schema'; +import { + BuildOutputAsset, + PrerenderedRoutesRecord, +} from '../../tools/esbuild/bundler-execution-result'; +import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-files'; +import { assertIsError } from '../error'; +import { toPosixPath } from '../path'; +import { addLeadingSlash, addTrailingSlash, joinUrlParts, stripLeadingSlash } from '../url'; +import { WorkerPool } from '../worker-pool'; +import { + IMPORT_EXEC_ARGV, + createSharedFile, + createSharedServerFiles, +} from './esm-in-memory-loader/utils'; +import { SERVER_APP_MANIFEST_FILENAME } from './manifest'; +import { + RouteRenderMode, + RoutersExtractorWorkerResult, + RoutesExtractorWorkerData, + SerializableRouteTreeNode, + WritableSerializableRouteTreeNode, +} from './models'; +import type { RenderResult, RenderWorkerData } from './render-worker'; +import { generateRedirectStaticPage } from './utils'; + +type PrerenderOptions = NormalizedApplicationBuildOptions['prerenderOptions']; +type AppShellOptions = NormalizedApplicationBuildOptions['appShellOptions']; + +/** + * Represents the output of a prerendering process. + * + * The key is the file path, and the value is an object containing the following properties: + * + * - `content`: The HTML content or output generated for the corresponding file path. + * - `appShellRoute`: A boolean flag indicating whether the content is an app shell. + * + * @example + * { + * '/index.html': { content: '...', appShell: false }, + * '/shell/index.html': { content: '...', appShellRoute: true } + * } + */ +type PrerenderOutput = Record; + +export async function prerenderPages( + workspaceRoot: string, + baseHref: string, + appShellOptions: AppShellOptions | undefined, + prerenderOptions: PrerenderOptions | undefined, + outputFiles: Readonly, + assets: Readonly, + outputMode: OutputMode | undefined, + sourcemap = false, + maxThreads = 1, +): Promise<{ + output: PrerenderOutput; + warnings: string[]; + errors: string[]; + prerenderedRoutes: PrerenderedRoutesRecord; + serializableRouteTreeNode: SerializableRouteTreeNode; +}> { + const rawOutputFiles: Record = {}; + const serverBundlesSourceMaps = new Map(); + const warnings: string[] = []; + const errors: string[] = []; + + for (const { text, path, type } of outputFiles) { + if (type !== BuildOutputFileType.ServerApplication && type !== BuildOutputFileType.ServerRoot) { + continue; + } + + // Contains the server runnable application code + if (extname(path) === '.map') { + serverBundlesSourceMaps.set(path.slice(0, -4), text); + } else { + rawOutputFiles[path] = text; + } + } + + // Inline sourcemap into JS file. This is needed to make Node.js resolve sourcemaps + // when using `--enable-source-maps` when using in memory files. + for (const [filePath, map] of serverBundlesSourceMaps) { + const jsContent = rawOutputFiles[filePath]; + if (jsContent) { + rawOutputFiles[filePath] = + jsContent + + '\n//# sourceMappingURL=' + + `data:application/json;base64,${Buffer.from(map).toString('base64')}`; + } + } + serverBundlesSourceMaps.clear(); + + const outputFilesForWorker = createSharedServerFiles(rawOutputFiles); + + const assetsReversed: Record = {}; + for (const { source, destination } of assets) { + // Assets are not stored with baseHref when using i18n, + // we append the base href so that requests are resolved correctly. + assetsReversed[joinUrlParts(baseHref, toPosixPath(destination))] = source; + } + + // Get routes to prerender + const { + errors: extractionErrors, + serializedRouteTree: serializableRouteTreeNode, + appShellRoute, + } = await getAllRoutes( + workspaceRoot, + baseHref, + outputFilesForWorker, + assetsReversed, + appShellOptions, + prerenderOptions, + sourcemap, + outputMode, + ).catch((err) => { + assertIsError(err); + + return { + errors: [ + `An error occurred while extracting routes.\n\n${err.stack ?? err.message ?? err.code ?? err}`, + ], + serializedRouteTree: [], + appShellRoute: undefined, + }; + }); + + errors.push(...extractionErrors); + + const serializableRouteTreeNodeForPrerender: WritableSerializableRouteTreeNode = []; + for (const metadata of serializableRouteTreeNode) { + if (outputMode !== OutputMode.Static && metadata.redirectTo) { + // Skip redirects if output mode is not static. + continue; + } + + if (metadata.route.includes('*')) { + // Skip catch all routes from prerender. + continue; + } + + switch (metadata.renderMode) { + case undefined: /* Legacy building mode */ + case RouteRenderMode.Prerender: + serializableRouteTreeNodeForPrerender.push(metadata); + break; + case RouteRenderMode.Server: + if (outputMode === OutputMode.Static) { + errors.push( + `Route '${metadata.route}' is configured with server render mode, but the build 'outputMode' is set to 'static'.`, + ); + } + break; + } + } + + if (!serializableRouteTreeNodeForPrerender.length || errors.length > 0) { + return { + errors, + warnings, + output: {}, + prerenderedRoutes: {}, + serializableRouteTreeNode, + }; + } + + // Add the extracted routes to the manifest file. + // We could re-generate it from the start, but that would require a number of options to be passed down. + const manifest = outputFilesForWorker[SERVER_APP_MANIFEST_FILENAME]; + if (manifest) { + const manifestText = new TextDecoder().decode(manifest); + + outputFilesForWorker[SERVER_APP_MANIFEST_FILENAME] = createSharedFile( + manifestText.replace( + 'routes: undefined,', + `routes: ${JSON.stringify(serializableRouteTreeNodeForPrerender, undefined, 2)},`, + ), + ); + } + + // Render routes + const { errors: renderingErrors, output } = await renderPages( + baseHref, + sourcemap, + serializableRouteTreeNodeForPrerender, + maxThreads, + workspaceRoot, + outputFilesForWorker, + assetsReversed, + outputMode, + prerenderOptions?.format, + appShellRoute ?? appShellOptions?.route, + ); + + errors.push(...renderingErrors); + + const prerenderedRoutes: PrerenderedRoutesRecord = {}; + const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname; + + for (const metadata of serializableRouteTreeNodeForPrerender) { + const outPath = getRouteOutPath( + metadata.route, + baseHrefPathnameWithLeadingSlash, + prerenderOptions?.format, + ); + + if (output[outPath]) { + prerenderedRoutes[metadata.route] = { headers: metadata.headers }; + } + } + + return { + errors, + warnings, + output, + prerenderedRoutes, + serializableRouteTreeNode, + }; +} + +async function renderPages( + baseHref: string, + sourcemap: boolean, + serializableRouteTreeNode: SerializableRouteTreeNode, + maxThreads: number, + workspaceRoot: string, + outputFilesForWorker: Record, + assetFilesForWorker: Record, + outputMode: OutputMode | undefined, + format: PrerenderFormat | undefined, + appShellRoute: string | undefined, +): Promise<{ + output: PrerenderOutput; + errors: string[]; +}> { + const output: PrerenderOutput = {}; + const errors: string[] = []; + + const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname; + const appShellRouteWithoutBaseHref = appShellRoute + ? addLeadingSlash(getRouteWithoutBaseHref(appShellRoute, baseHrefPathnameWithLeadingSlash)) + : undefined; + + const routesToRender: { route: string; outPath: string; isAppShell: boolean }[] = []; + + for (const { route, redirectTo } of serializableRouteTreeNode) { + // Remove the base href from the file output path. + const routeWithoutBaseHref = getRouteWithoutBaseHref(route, baseHrefPathnameWithLeadingSlash); + const outPath = getRouteOutPath(route, baseHrefPathnameWithLeadingSlash, format); + + if (typeof redirectTo === 'string') { + output[outPath] = { content: generateRedirectStaticPage(redirectTo), appShellRoute: false }; + + continue; + } + + routesToRender.push({ + route, + outPath, + isAppShell: appShellRouteWithoutBaseHref === routeWithoutBaseHref, + }); + } + + if (routesToRender.length === 0) { + return { + errors, + output, + }; + } + + // Batch routes to reduce IPC overhead while ensuring enough batches exist for load balancing across worker threads. + const batchSize = Math.max(1, Math.min(50, Math.ceil(routesToRender.length / (maxThreads * 4)))); + const numBatches = Math.ceil(routesToRender.length / batchSize); + const effectiveMaxThreads = Math.min(numBatches, maxThreads); + + const workerExecArgv = [IMPORT_EXEC_ARGV]; + if (sourcemap) { + workerExecArgv.push('--enable-source-maps'); + } + + const renderWorker = new WorkerPool({ + filename: require.resolve('./render-worker'), + maxThreads: effectiveMaxThreads, + workerData: { + workspaceRoot, + outputFiles: outputFilesForWorker, + assetFiles: assetFilesForWorker, + outputMode, + hasSsrEntry: !!outputFilesForWorker['server.mjs'], + } as RenderWorkerData, + execArgv: workerExecArgv, + env: { + ...process.env, + 'NG_ALLOWED_HOSTS': 'localhost', + }, + }); + + try { + const routeOutPathMap = new Map(); + for (const item of routesToRender) { + routeOutPathMap.set(item.route, item); + } + + const renderingPromises: Promise[] = []; + + for (let i = 0; i < routesToRender.length; i += batchSize) { + const batch = routesToRender.slice(i, i + batchSize); + const urls = batch.map((item) => item.route); + const renderBatchPromise: Promise = renderWorker.run(urls); + const batchResultPromise = renderBatchPromise + .then((results) => { + for (const result of results) { + if ('error' in result) { + errors.push( + `An error occurred while prerendering route '${result.url}'.\n\n${result.error}`, + ); + continue; + } + + const routeInfo = routeOutPathMap.get(result.url); + if (routeInfo) { + output[routeInfo.outPath] = { + content: result.content, + appShellRoute: routeInfo.isAppShell, + }; + } + } + }) + .catch((err) => { + assertIsError(err); + for (const url of urls) { + errors.push( + `An error occurred while prerendering route '${url}'.\n\n${err.stack ?? err.message ?? err.code ?? err}`, + ); + } + }); + + renderingPromises.push(batchResultPromise); + } + + await Promise.all(renderingPromises); + } finally { + void renderWorker.destroy(); + } + + return { + errors, + output, + }; +} + +async function getAllRoutes( + workspaceRoot: string, + baseHref: string, + outputFilesForWorker: Record, + assetFilesForWorker: Record, + appShellOptions: AppShellOptions | undefined, + prerenderOptions: PrerenderOptions | undefined, + sourcemap: boolean, + outputMode: OutputMode | undefined, +): Promise<{ + serializedRouteTree: SerializableRouteTreeNode; + appShellRoute?: string; + errors: string[]; +}> { + const { routesFile, discoverRoutes } = prerenderOptions ?? {}; + const routes: WritableSerializableRouteTreeNode = []; + let appShellRoute: string | undefined; + + if (appShellOptions) { + appShellRoute = joinUrlParts(baseHref, appShellOptions.route); + + routes.push({ + renderMode: RouteRenderMode.Prerender, + route: appShellRoute, + }); + } + + if (routesFile) { + const routesFromFile = (await readFile(routesFile, 'utf8')).split(/\r?\n/); + for (const route of routesFromFile) { + routes.push({ + renderMode: RouteRenderMode.Prerender, + route: joinUrlParts(baseHref, route.trim()), + }); + } + } + + if (!discoverRoutes) { + return { errors: [], appShellRoute, serializedRouteTree: routes }; + } + + const workerExecArgv = [IMPORT_EXEC_ARGV]; + + if (sourcemap) { + workerExecArgv.push('--enable-source-maps'); + } + + const renderWorker = new WorkerPool({ + filename: require.resolve('./routes-extractor-worker'), + maxThreads: 1, + workerData: { + workspaceRoot, + outputFiles: outputFilesForWorker, + assetFiles: assetFilesForWorker, + outputMode, + hasSsrEntry: !!outputFilesForWorker['server.mjs'], + } as RoutesExtractorWorkerData, + execArgv: workerExecArgv, + env: { + ...process.env, + 'NG_ALLOWED_HOSTS': 'localhost', + }, + }); + + try { + const { serializedRouteTree, appShellRoute, errors }: RoutersExtractorWorkerResult = + await renderWorker.run({}); + + if (!routes.length) { + return { errors, appShellRoute, serializedRouteTree }; + } + + // Merge the routing trees + const uniqueRoutes = new Map(); + for (const item of [...routes, ...serializedRouteTree]) { + if (!uniqueRoutes.has(item.route)) { + uniqueRoutes.set(item.route, item); + } + } + + return { errors, serializedRouteTree: Array.from(uniqueRoutes.values()) }; + } catch (err) { + assertIsError(err); + + return { + errors: [ + `An error occurred while extracting routes.\n\n${err.stack ?? err.message ?? err.code ?? err}`, + ], + serializedRouteTree: [], + }; + } finally { + void renderWorker.destroy(); + } +} + +function getRouteWithoutBaseHref(route: string, baseHrefPathname: string): string { + return addTrailingSlash(route).startsWith(baseHrefPathname) + ? addLeadingSlash(route.slice(baseHrefPathname.length)) + : route; +} + +function getRouteOutPath( + route: string, + baseHrefPathname: string, + format: PrerenderFormat | undefined, +): string { + const routeWithoutBaseHref = getRouteWithoutBaseHref(route, baseHrefPathname); + + if (format === PrerenderFormat.File && routeWithoutBaseHref !== '/') { + return stripLeadingSlash(`${routeWithoutBaseHref}.html`); + } + + return stripLeadingSlash(posix.join(routeWithoutBaseHref, 'index.html')); +} diff --git a/packages/angular/build/src/utils/server-rendering/render-worker.ts b/packages/angular/build/src/utils/server-rendering/render-worker.ts new file mode 100644 index 000000000000..df1fdb4ea103 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/render-worker.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { workerData } from 'node:worker_threads'; +import type { OutputMode } from '../../builders/application/schema'; +import { assertIsError } from '../error'; +import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; +import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; +import { DEFAULT_URL, launchServer } from './launch-server'; +import { loadEsmModuleFromMemory } from './load-esm-from-memory'; +import { generateRedirectStaticPage } from './utils'; + +export interface RenderWorkerData extends ESMInMemoryFileLoaderWorkerData { + assetFiles: Record; + outputMode: OutputMode | undefined; + hasSsrEntry: boolean; +} + +export type RenderResultItem = + | { + url: string; + content: string; + } + | { + url: string; + error: string; + }; + +export type RenderResult = RenderResultItem[]; + +/** + * This is passed as workerData when setting up the worker via the `piscina` package. + */ +const { outputMode, hasSsrEntry } = workerData as { + outputMode: OutputMode | undefined; + hasSsrEntry: boolean; +}; + +let serverURL = DEFAULT_URL; + +/** + * Renders a single route URL. + */ +async function renderPage( + url: string, + angularServerApp: { handle: (request: Request) => Promise }, +): Promise { + const response = await angularServerApp.handle( + new Request(new URL(url, serverURL), { signal: AbortSignal.timeout(30_000) }), + ); + + if (!response) { + return null; + } + + const location = response.headers.get('Location'); + + return location ? generateRedirectStaticPage(location) : response.text(); +} + +/** + * Renders routes in batch or individual URL. + */ +async function renderPages(urls: string[]): Promise { + const { ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp } = + await loadEsmModuleFromMemory('./main.server.mjs'); + + const angularServerApp = getOrCreateAngularServerApp({ + allowStaticRouteRender: true, + }); + + const results: RenderResult = []; + for (const currentUrl of urls) { + try { + const content = await renderPage(currentUrl, angularServerApp); + + if (content === null) { + throw new Error('The content returned was empty.'); + } + + results.push({ url: currentUrl, content }); + } catch (err) { + assertIsError(err); + results.push({ + url: currentUrl, + error: err.stack ?? err.message ?? err.code ?? `${err}`, + }); + } + } + + return results; +} + +async function initialize() { + // Load the compiler because `@angular/ssr/node` depends on `@angular/` packages, + // which must be processed by the runtime linker, even if they are not used. + await import('@angular/compiler'); + + if (outputMode !== undefined && hasSsrEntry) { + serverURL = await launchServer(); + } + + patchFetchToLoadInMemoryAssets(serverURL); + + return renderPages; +} + +export default initialize(); diff --git a/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts new file mode 100644 index 000000000000..423a71e83ba5 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { workerData } from 'node:worker_threads'; +import { OutputMode } from '../../builders/application/schema'; +import { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; +import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; +import { DEFAULT_URL, launchServer } from './launch-server'; +import { loadEsmModuleFromMemory } from './load-esm-from-memory'; +import { RoutersExtractorWorkerResult } from './models'; + +export interface ExtractRoutesWorkerData extends ESMInMemoryFileLoaderWorkerData { + outputMode: OutputMode | undefined; +} + +/** + * This is passed as workerData when setting up the worker via the `piscina` package. + */ +const { outputMode, hasSsrEntry } = workerData as { + outputMode: OutputMode | undefined; + hasSsrEntry: boolean; +}; + +/** Renders an application based on a provided options. */ +async function extractRoutes(): Promise { + // Load the compiler because `@angular/ssr/node` depends on `@angular/` packages, + // which must be processed by the runtime linker, even if they are not used. + await import('@angular/compiler'); + + const serverURL = outputMode !== undefined && hasSsrEntry ? await launchServer() : DEFAULT_URL; + + patchFetchToLoadInMemoryAssets(serverURL); + + const { ɵextractRoutesAndCreateRouteTree: extractRoutesAndCreateRouteTree } = + await loadEsmModuleFromMemory('./main.server.mjs'); + + const { routeTree, appShellRoute, errors } = await extractRoutesAndCreateRouteTree({ + url: serverURL, + invokeGetPrerenderParams: outputMode !== undefined, + includePrerenderFallbackRoutes: outputMode === OutputMode.Server, + signal: AbortSignal.timeout(30_000), + }); + + return { + errors, + appShellRoute, + serializedRouteTree: routeTree.toObject(), + }; +} + +export default extractRoutes; diff --git a/packages/angular/build/src/utils/server-rendering/utils.ts b/packages/angular/build/src/utils/server-rendering/utils.ts new file mode 100644 index 000000000000..18ab7def9ec1 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/utils.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { createRequestHandler } from '@angular/ssr'; +import type { createNodeRequestHandler } from '@angular/ssr/node' with { + 'resolution-mode': 'import', +}; + +export function isSsrNodeRequestHandler( + value: unknown, +): value is ReturnType { + return typeof value === 'function' && '__ng_node_request_handler__' in value; +} +export function isSsrRequestHandler( + value: unknown, +): value is ReturnType { + return typeof value === 'function' && '__ng_request_handler__' in value; +} + +/** + * A mapping of the characters which have to be escaped to be interpolated into HTML, + * to their entity equivalents. + */ +const HTML_ESCAPE_CHARACTER_MAP: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +/** + * Escapes the characters of a value which is interpolated into HTML text or into a quoted + * attribute value. + * + * @param text - The value to escape. + * @returns The escaped value. + */ +function escapeHtml(text: string): string { + return text.replace(/[&<>"']/g, (character) => HTML_ESCAPE_CHARACTER_MAP[character]); +} + +/** + * Generates a static HTML page with a meta refresh tag to redirect the user to a specified URL. + * + * This function creates a simple HTML page that performs a redirect using a meta tag. + * It includes a fallback link in case the meta-refresh doesn't work. + * + * The provided URL is HTML-escaped before being interpolated. + * + * @param url - The URL to which the page should redirect. + * @returns The HTML content of the static redirect page. + */ +export function generateRedirectStaticPage(url: string): string { + const escapedUrl = escapeHtml(url); + + return ` + + + + + Redirecting + + + +
Redirecting to ${escapedUrl}
+ + +`.trim(); +} diff --git a/packages/angular/build/src/utils/server-rendering/utils_spec.ts b/packages/angular/build/src/utils/server-rendering/utils_spec.ts new file mode 100644 index 000000000000..c8d738f8eec1 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/utils_spec.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { generateRedirectStaticPage } from './utils'; + +describe('generateRedirectStaticPage', () => { + it('escapes the ampersands of a redirect target', () => { + const page = generateRedirectStaticPage('https://example.com/docs?from=ssg&next=/ssg'); + + expect(page).toContain( + '', + ); + expect(page).toContain( + '' + + 'https://example.com/docs?from=ssg&next=/ssg', + ); + }); + + it('escapes characters which would break out of the attribute or the tag', () => { + const page = generateRedirectStaticPage(`/">`); + + expect(page).not.toContain(' - - - - - %SCRIPTS% - - - - - - - - diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/karma-debug.html b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/karma-debug.html deleted file mode 100644 index 79d8708fdc87..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/karma-debug.html +++ /dev/null @@ -1,44 +0,0 @@ - - - - - -%X_UA_COMPATIBLE% - Karma DEBUG RUNNER - - - - - - - - - - - - - - %SCRIPTS% - - - - - - - - diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/karma-webpack-failure-cb.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/karma-webpack-failure-cb.ts deleted file mode 100644 index b4c02479bd6e..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/karma-webpack-failure-cb.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable -// TODO: cleanup this file, it's copied as is from Angular CLI. - -// Force Webpack to throw compilation errors. Useful with karma-webpack when in single-run mode. -// Workaround for https://github.com/webpack-contrib/karma-webpack/issues/66 - -export class KarmaWebpackFailureCb { - constructor(private callback: (error: string | undefined, errors: string[]) => void) { } - - apply(compiler: any): void { - compiler.hooks.done.tap('KarmaWebpackFailureCb', (stats: any) => { - if (stats.compilation.errors.length > 0) { - this.callback(undefined, stats.compilation.errors.map((error: any) => error.message? error.message : error.toString())); - } - }); - } -} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/karma.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/karma.ts deleted file mode 100644 index 54de40d22155..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/karma.ts +++ /dev/null @@ -1,314 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable -// TODO: cleanup this file, it's copied as is from Angular CLI. - -import * as path from 'path'; -import * as fs from 'fs'; -import * as glob from 'glob'; -import * as webpack from 'webpack'; -const webpackDevMiddleware = require('webpack-dev-middleware'); - -import { AssetPattern } from '../../browser/schema'; -import { KarmaWebpackFailureCb } from './karma-webpack-failure-cb'; - -/** - * Enumerate needed (but not require/imported) dependencies from this file - * to let the dependency validator know they are used. - * - * require('source-map-support') - * require('karma-source-map-support') - */ - - -let blocked: any[] = []; -let isBlocked = false; -let webpackMiddleware: any; -let successCb: () => void; -let failureCb: () => void; - -// Add files to the Karma files array. -function addKarmaFiles(files: any[], newFiles: any[], prepend = false) { - const defaults = { - included: true, - served: true, - watched: true - }; - - const processedFiles = newFiles - // Remove globs that do not match any files, otherwise Karma will show a warning for these. - .filter(file => glob.sync(file.pattern, { nodir: true }).length != 0) - // Fill in pattern properties with defaults. - .map(file => ({ ...defaults, ...file })); - - // It's important to not replace the array, because - // karma already has a reference to the existing array. - if (prepend) { - files.unshift(...processedFiles); - } else { - files.push(...processedFiles); - } -} - -const init: any = (config: any, emitter: any, customFileHandlers: any) => { - if (!config.buildWebpack) { - throw new Error(`The '@angular-devkit/build-angular/plugins/karma' karma plugin is meant to` + - ` be used from within Angular CLI and will not work correctly outside of it.` - ) - } - const options = config.buildWebpack.options; - const projectRoot = config.buildWebpack.projectRoot as string; - successCb = config.buildWebpack.successCb; - failureCb = config.buildWebpack.failureCb; - - config.reporters.unshift('@angular-devkit/build-angular--event-reporter'); - - // When using code-coverage, auto-add coverage-istanbul. - config.reporters = config.reporters || []; - if (options.codeCoverage && config.reporters.indexOf('coverage-istanbul') === -1) { - config.reporters.unshift('coverage-istanbul'); - } - - // Add a reporter that fixes sourcemap urls. - if (options.sourceMap) { - config.reporters.unshift('@angular-devkit/build-angular--sourcemap-reporter'); - - // Code taken from https://github.com/tschaub/karma-source-map-support. - // We can't use it directly because we need to add it conditionally in this file, and karma - // frameworks cannot be added dynamically. - const smsPath = path.dirname(require.resolve('source-map-support')); - const ksmsPath = path.dirname(require.resolve('karma-source-map-support')); - - addKarmaFiles(config.files, [ - { pattern: path.join(smsPath, 'browser-source-map-support.js'), watched: false }, - { pattern: path.join(ksmsPath, 'client.js'), watched: false } - ], true); - } - - // Add webpack config. - const webpackConfig = config.buildWebpack.webpackConfig; - const webpackMiddlewareConfig = { - logLevel: 'error', // Hide webpack output because its noisy. - watchOptions: { poll: options.poll }, - publicPath: '/_karma_webpack_/', - }; - - const compilationErrorCb = (error: string | undefined, errors: string[]) => { - // Notify potential listeners of the compile error - emitter.emit('compile_error', errors); - - // Finish Karma run early in case of compilation error. - emitter.emit('run_complete', [], { exitCode: 1 }); - - // Unblock any karma requests (potentially started using `karma run`) - unblock(); - } - webpackConfig.plugins.push(new KarmaWebpackFailureCb(compilationErrorCb)); - - // Use existing config if any. - config.webpack = Object.assign(webpackConfig, config.webpack); - config.webpackMiddleware = Object.assign(webpackMiddlewareConfig, config.webpackMiddleware); - - // Our custom context and debug files list the webpack bundles directly instead of using - // the karma files array. - config.customContextFile = `${__dirname}/karma-context.html`; - config.customDebugFile = `${__dirname}/karma-debug.html`; - - // Add the request blocker and the webpack server fallback. - config.beforeMiddleware = config.beforeMiddleware || []; - config.beforeMiddleware.push('@angular-devkit/build-angular--blocker'); - config.middleware = config.middleware || []; - config.middleware.push('@angular-devkit/build-angular--fallback'); - - // The webpack tier owns the watch behavior so we want to force it in the config. - webpackConfig.watch = !config.singleRun; - if (config.singleRun) { - // There's no option to turn off file watching in webpack-dev-server, but - // we can override the file watcher instead. - webpackConfig.plugins.unshift({ - apply: (compiler: any) => { // tslint:disable-line:no-any - compiler.hooks.afterEnvironment.tap('karma', () => { - compiler.watchFileSystem = { watch: () => { } }; - }); - }, - }); - } - // Files need to be served from a custom path for Karma. - webpackConfig.output.path = '/_karma_webpack_/'; - webpackConfig.output.publicPath = '/_karma_webpack_/'; - webpackConfig.output.devtoolModuleFilenameTemplate = '[namespace]/[resource-path]?[loaders]'; - - let compiler: any; - try { - compiler = webpack(webpackConfig); - } catch (e) { - console.error(e.stack || e); - if (e.details) { - console.error(e.details); - } - throw e; - } - - function handler(callback?: () => void) { - isBlocked = true; - - if (typeof callback === 'function') { - callback(); - } - } - - compiler.hooks.invalid.tap('karma', () => handler()); - - compiler.hooks.watchRun.tapAsync('karma', (_: any, callback: () => void) => handler(callback)); - - compiler.hooks.run.tapAsync('karma', (_: any, callback: () => void) => handler(callback)); - - function unblock(){ - isBlocked = false; - blocked.forEach((cb) => cb()); - blocked = []; - } - - let lastCompilationHash: string | undefined; - compiler.hooks.done.tap('karma', (stats: any) => { - // Refresh karma only when there are no webpack errors, and if the compilation changed. - if (stats.compilation.errors.length === 0 && stats.hash != lastCompilationHash) { - lastCompilationHash = stats.hash; - emitter.refreshFiles(); - } - unblock(); - }); - - webpackMiddleware = new webpackDevMiddleware(compiler, webpackMiddlewareConfig); - - // Forward requests to webpack server. - customFileHandlers.push({ - urlRegex: /^\/_karma_webpack_\/.*/, - handler: function handler(req: any, res: any) { - webpackMiddleware(req, res, function () { - // Ensure script and style bundles are served. - // They are mentioned in the custom karma context page and we don't want them to 404. - const alwaysServe = [ - '/_karma_webpack_/runtime.js', - '/_karma_webpack_/polyfills.js', - '/_karma_webpack_/scripts.js', - '/_karma_webpack_/styles.js', - '/_karma_webpack_/vendor.js', - ]; - if (alwaysServe.indexOf(req.url) != -1) { - res.statusCode = 200; - res.end(); - } else { - res.statusCode = 404; - res.end('Not found'); - } - }); - } - }); - - emitter.on('exit', (done: any) => { - webpackMiddleware.close(); - done(); - }); -}; - -init.$inject = ['config', 'emitter', 'customFileHandlers']; - -// Block requests until the Webpack compilation is done. -function requestBlocker() { - return function (_request: any, _response: any, next: () => void) { - if (isBlocked) { - blocked.push(next); - } else { - next(); - } - }; -} - -// Copied from "karma-jasmine-diff-reporter" source code: -// In case, when multiple reporters are used in conjunction -// with initSourcemapReporter, they both will show repetitive log -// messages when displaying everything that supposed to write to terminal. -// So just suppress any logs from initSourcemapReporter by doing nothing on -// browser log, because it is an utility reporter, -// unless it's alone in the "reporters" option and base reporter is used. -function muteDuplicateReporterLogging(context: any, config: any) { - context.writeCommonMsg = function () { }; - const reporterName = '@angular/cli'; - const hasTrailingReporters = config.reporters.slice(-1).pop() !== reporterName; - - if (hasTrailingReporters) { - context.writeCommonMsg = function () { }; - } -} - -// Emits builder events. -const eventReporter: any = function (this: any, baseReporterDecorator: any, config: any) { - baseReporterDecorator(this); - - muteDuplicateReporterLogging(this, config); - - this.onRunComplete = function (_browsers: any, results: any) { - if (results.exitCode === 0) { - successCb && successCb(); - } else { - failureCb && failureCb(); - } - } - - // avoid duplicate failure message - this.specFailure = () => {}; -}; - -eventReporter.$inject = ['baseReporterDecorator', 'config']; - -// Strip the server address and webpack scheme (webpack://) from error log. -const sourceMapReporter: any = function (this: any, baseReporterDecorator: any, config: any) { - baseReporterDecorator(this); - - muteDuplicateReporterLogging(this, config); - - const urlRegexp = /\(http:\/\/localhost:\d+\/_karma_webpack_\/webpack:\//gi; - - this.onSpecComplete = function (_browser: any, result: any) { - if (!result.success && result.log.length > 0) { - result.log.forEach((log: string, idx: number) => { - result.log[idx] = log.replace(urlRegexp, ''); - }); - } - }; - - // avoid duplicate complete message - this.onRunComplete = () => {}; - - // avoid duplicate failure message - this.specFailure = () => {}; -}; - -sourceMapReporter.$inject = ['baseReporterDecorator', 'config']; - -// When a request is not found in the karma server, try looking for it from the webpack server root. -function fallbackMiddleware() { - return function (req: any, res: any, next: () => void) { - if (webpackMiddleware) { - const webpackUrl = '/_karma_webpack_' + req.url; - const webpackReq = { ...req, url: webpackUrl } - webpackMiddleware(webpackReq, res, next); - } else { - next(); - } - }; -} - -module.exports = { - 'framework:@angular-devkit/build-angular': ['factory', init], - 'reporter:@angular-devkit/build-angular--sourcemap-reporter': ['type', sourceMapReporter], - 'reporter:@angular-devkit/build-angular--event-reporter': ['type', eventReporter], - 'middleware:@angular-devkit/build-angular--blocker': ['factory', requestBlocker], - 'middleware:@angular-devkit/build-angular--fallback': ['factory', fallbackMiddleware] -}; diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/postcss-cli-resources.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/postcss-cli-resources.ts deleted file mode 100644 index 0a5816ad0925..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/postcss-cli-resources.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * @license - * 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 - */ -import { interpolateName } from 'loader-utils'; -import * as path from 'path'; -import * as postcss from 'postcss'; -import * as url from 'url'; -import * as webpack from 'webpack'; - -function wrapUrl(url: string): string { - let wrappedUrl; - const hasSingleQuotes = url.indexOf('\'') >= 0; - - if (hasSingleQuotes) { - wrappedUrl = `"${url}"`; - } else { - wrappedUrl = `'${url}'`; - } - - return `url(${wrappedUrl})`; -} - -export interface PostcssCliResourcesOptions { - baseHref?: string; - deployUrl?: string; - filename: string; - loader: webpack.loader.LoaderContext; -} - -async function resolve( - file: string, - base: string, - resolver: (file: string, base: string) => Promise, -): Promise { - try { - return await resolver('./' + file, base); - } catch { - return resolver(file, base); - } -} - -export default postcss.plugin('postcss-cli-resources', (options: PostcssCliResourcesOptions) => { - const { - deployUrl = '', - baseHref = '', - filename, - loader, - } = options; - - const dedupeSlashes = (url: string) => url.replace(/\/\/+/g, '/'); - - const process = async (inputUrl: string, context: string, resourceCache: Map) => { - // If root-relative, absolute or protocol relative url, leave as is - if (/^((?:\w+:)?\/\/|data:|chrome:|#)/.test(inputUrl)) { - return inputUrl; - } - - // If starts with a caret, remove and return remainder - // this supports bypassing asset processing - if (inputUrl.startsWith('^')) { - return inputUrl.substr(1); - } - - const cacheKey = path.resolve(context, inputUrl); - const cachedUrl = resourceCache.get(cacheKey); - if (cachedUrl) { - return cachedUrl; - } - - if (inputUrl.startsWith('~')) { - inputUrl = inputUrl.substr(1); - } - - if (inputUrl.startsWith('/')) { - let outputUrl = ''; - if (deployUrl.match(/:\/\//) || deployUrl.startsWith('/')) { - // If deployUrl is absolute or root relative, ignore baseHref & use deployUrl as is. - outputUrl = `${deployUrl.replace(/\/$/, '')}${inputUrl}`; - } else if (baseHref.match(/:\/\//)) { - // If baseHref contains a scheme, include it as is. - outputUrl = baseHref.replace(/\/$/, '') + dedupeSlashes(`/${deployUrl}/${inputUrl}`); - } else { - // Join together base-href, deploy-url and the original URL. - outputUrl = dedupeSlashes(`/${baseHref}/${deployUrl}/${inputUrl}`); - } - - resourceCache.set(cacheKey, outputUrl); - - return outputUrl; - } - - const { pathname, hash, search } = url.parse(inputUrl.replace(/\\/g, '/')); - const resolver = (file: string, base: string) => new Promise((resolve, reject) => { - loader.resolve(base, file, (err, result) => { - if (err) { - reject(err); - - return; - } - resolve(result); - }); - }); - - const result = await resolve(pathname as string, context, resolver); - - return new Promise((resolve, reject) => { - loader.fs.readFile(result, (err: Error, content: Buffer) => { - if (err) { - reject(err); - - return; - } - - const outputPath = interpolateName( - { resourcePath: result } as webpack.loader.LoaderContext, - filename, - { content }, - ); - - loader.addDependency(result); - loader.emitFile(outputPath, content, undefined); - - let outputUrl = outputPath.replace(/\\/g, '/'); - if (hash || search) { - outputUrl = url.format({ pathname: outputUrl, hash, search }); - } - - if (deployUrl && loader.loaders[loader.loaderIndex].options.ident !== 'extracted') { - outputUrl = url.resolve(deployUrl, outputUrl); - } - - resourceCache.set(cacheKey, outputUrl); - resolve(outputUrl); - }); - }); - }; - - return (root) => { - const urlDeclarations: Array = []; - root.walkDecls(decl => { - if (decl.value && decl.value.includes('url')) { - urlDeclarations.push(decl); - } - }); - - if (urlDeclarations.length === 0) { - return; - } - - const resourceCache = new Map(); - - return Promise.all(urlDeclarations.map(async decl => { - const value = decl.value; - const urlRegex = /url\(\s*(?:"([^"]+)"|'([^']+)'|(.+?))\s*\)/g; - const segments: string[] = []; - - let match; - let lastIndex = 0; - let modified = false; - - // We want to load it relative to the file that imports - const context = path.dirname(decl.source.input.file); - - // tslint:disable-next-line:no-conditional-assignment - while (match = urlRegex.exec(value)) { - const originalUrl = match[1] || match[2] || match[3]; - let processedUrl; - try { - processedUrl = await process(originalUrl, context, resourceCache); - } catch (err) { - loader.emitError(decl.error(err.message, { word: originalUrl }).toString()); - continue; - } - - if (lastIndex < match.index) { - segments.push(value.slice(lastIndex, match.index)); - } - - if (!processedUrl || originalUrl === processedUrl) { - segments.push(match[0]); - } else { - segments.push(wrapUrl(processedUrl)); - modified = true; - } - - lastIndex = match.index + match[0].length; - } - - if (lastIndex < value.length) { - segments.push(value.slice(lastIndex)); - } - - if (modified) { - decl.value = segments.join(''); - } - })); - }; -}); diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/raw-css-loader.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/raw-css-loader.ts deleted file mode 100644 index 9f7ff68b3032..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/raw-css-loader.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @license - * 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 - */ - -export default function(content: string, map: object) { - const stringifiedContent = JSON.stringify(content); - const stringifiedMap = map ? JSON.stringify(map) : `''`; - - return `module.exports = [[module.id, ${stringifiedContent}, '', ${stringifiedMap}]]`; -} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/remove-hash-plugin.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/remove-hash-plugin.ts deleted file mode 100644 index 0a9717bea1ed..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/remove-hash-plugin.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * @license - * 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 - */ -import { Compiler, compilation } from 'webpack'; -import { HashFormat } from '../models/webpack-configs/utils'; - - -export interface RemoveHashPluginOptions { - chunkNames: string[]; - hashFormat: HashFormat; -} - -export class RemoveHashPlugin { - - constructor(private options: RemoveHashPluginOptions) { } - - apply(compiler: Compiler): void { - compiler.hooks.compilation.tap('remove-hash-plugin', compilation => { - const mainTemplate = compilation.mainTemplate as compilation.MainTemplate & { - hooks: compilation.CompilationHooks; - }; - - mainTemplate.hooks.assetPath.tap('remove-hash-plugin', - (path: string, data: { chunk?: { name: string } }) => { - const chunkName = data.chunk && data.chunk.name; - const { chunkNames, hashFormat } = this.options; - - if (chunkName && chunkNames.includes(chunkName)) { - // Replace hash formats with empty strings. - return path - .replace(hashFormat.chunk, '') - .replace(hashFormat.extract, ''); - } - - return path; - }, - ); - }); - } -} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/scripts-webpack-plugin.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/scripts-webpack-plugin.ts deleted file mode 100644 index d832a124adf6..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/scripts-webpack-plugin.ts +++ /dev/null @@ -1,190 +0,0 @@ -// tslint:disable -// TODO: cleanup this file, it's copied as is from Angular CLI. - -/** - * @license - * 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 - */ -/** - * @license - * 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 - */ -import { Compiler, loader } from 'webpack'; -import { CachedSource, ConcatSource, OriginalSource, RawSource, Source } from 'webpack-sources'; -import { interpolateName } from 'loader-utils'; -import * as path from 'path'; - -const Chunk = require('webpack/lib/Chunk'); -const EntryPoint = require('webpack/lib/Entrypoint'); - -export interface ScriptsWebpackPluginOptions { - name: string; - sourceMap: boolean; - scripts: string[]; - filename: string; - basePath: string; -} - -interface ScriptOutput { - filename: string; - source: CachedSource; -} - -function addDependencies(compilation: any, scripts: string[]): void { - if (compilation.fileDependencies.add) { - // Webpack 4+ uses a Set - for (const script of scripts) { - compilation.fileDependencies.add(script); - } - } else { - // Webpack 3 - compilation.fileDependencies.push(...scripts); - } -} - -function hook(compiler: any, action: (compilation: any, callback: (err?: Error) => void) => void) { - if (compiler.hooks) { - // Webpack 4 - compiler.hooks.thisCompilation.tap('scripts-webpack-plugin', (compilation: any) => { - compilation.hooks.additionalAssets.tapAsync( - 'scripts-webpack-plugin', - (callback: (err?: Error) => void) => action(compilation, callback), - ); - }); - } else { - // Webpack 3 - compiler.plugin('this-compilation', (compilation: any) => { - compilation.plugin( - 'additional-assets', - (callback: (err?: Error) => void) => action(compilation, callback), - ); - }); - } -} - -export class ScriptsWebpackPlugin { - private _lastBuildTime?: number; - private _cachedOutput?: ScriptOutput; - - constructor(private options: Partial = {}) { } - - shouldSkip(compilation: any, scripts: string[]): boolean { - if (this._lastBuildTime == undefined) { - this._lastBuildTime = Date.now(); - return false; - } - - for (let i = 0; i < scripts.length; i++) { - let scriptTime; - if (compilation.fileTimestamps.get) { - // Webpack 4+ uses a Map - scriptTime = compilation.fileTimestamps.get(scripts[i]); - } else { - // Webpack 3 - scriptTime = compilation.fileTimestamps[scripts[i]]; - } - if (!scriptTime || scriptTime > this._lastBuildTime) { - this._lastBuildTime = Date.now(); - return false; - } - } - - return true; - } - - private _insertOutput(compilation: any, { filename, source }: ScriptOutput, cached = false) { - const chunk = new Chunk(this.options.name); - chunk.rendered = !cached; - chunk.id = this.options.name; - chunk.ids = [chunk.id]; - chunk.files.push(filename); - - const entrypoint = new EntryPoint(this.options.name); - entrypoint.pushChunk(chunk); - - compilation.entrypoints.set(this.options.name, entrypoint); - compilation.chunks.push(chunk); - compilation.assets[filename] = source; - } - - apply(compiler: Compiler): void { - if (!this.options.scripts || this.options.scripts.length === 0) { - return; - } - - const scripts = this.options.scripts - .filter(script => !!script) - .map(script => path.resolve(this.options.basePath || '', script)); - - hook(compiler, (compilation, callback) => { - if (this.shouldSkip(compilation, scripts)) { - if (this._cachedOutput) { - this._insertOutput(compilation, this._cachedOutput, true); - } - - addDependencies(compilation, scripts); - callback(); - - return; - } - - const sourceGetters = scripts.map(fullPath => { - return new Promise((resolve, reject) => { - compilation.inputFileSystem.readFile(fullPath, (err: Error, data: Buffer) => { - if (err) { - reject(err); - return; - } - - const content = data.toString(); - - let source; - if (this.options.sourceMap) { - // TODO: Look for source map file (for '.min' scripts, etc.) - - let adjustedPath = fullPath; - if (this.options.basePath) { - adjustedPath = path.relative(this.options.basePath, fullPath); - } - source = new OriginalSource(content, adjustedPath); - } else { - source = new RawSource(content); - } - - resolve(source); - }); - }); - }); - - Promise.all(sourceGetters) - .then(sources => { - const concatSource = new ConcatSource(); - sources.forEach(source => { - concatSource.add(source); - concatSource.add('\n;'); - }); - - const combinedSource = new CachedSource(concatSource); - const filename = interpolateName( - { resourcePath: 'scripts.js' } as loader.LoaderContext, - this.options.filename as string, - { content: combinedSource.source() }, - ); - - const output = { filename, source: combinedSource }; - this._insertOutput(compilation, output); - this._cachedOutput = output; - addDependencies(compilation, scripts); - - callback(); - }) - .catch((err: Error) => callback(err)); - }); - } -} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/suppress-entry-chunks-webpack-plugin.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/suppress-entry-chunks-webpack-plugin.ts deleted file mode 100644 index c02bf346b9e6..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/suppress-entry-chunks-webpack-plugin.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable -// TODO: cleanup this file, it's copied as is from Angular CLI. - -// Remove .js files from entry points consisting entirely of .css|scss|sass|less|styl. -// To be used together with ExtractTextPlugin. - -export class SuppressExtractedTextChunksWebpackPlugin { - constructor() { } - - apply(compiler: any): void { - compiler.hooks.compilation.tap('SuppressExtractedTextChunks', (compilation: any) => { - // find which chunks have css only entry points - const cssOnlyChunks: string[] = []; - const entryPoints = compilation.options.entry; - // determine which entry points are composed entirely of css files - for (let entryPoint of Object.keys(entryPoints)) { - let entryFiles: string[] | string = entryPoints[entryPoint]; - // when type of entryFiles is not array, make it as an array - entryFiles = entryFiles instanceof Array ? entryFiles : [entryFiles]; - if (entryFiles.every((el: string) => - el.match(/\.(css|scss|sass|less|styl)$/) !== null)) { - cssOnlyChunks.push(entryPoint); - } - } - // Remove the js file for supressed chunks - compilation.hooks.afterSeal.tap('SuppressExtractedTextChunks', () => { - compilation.chunks - .filter((chunk: any) => cssOnlyChunks.indexOf(chunk.name) !== -1) - .forEach((chunk: any) => { - let newFiles: string[] = []; - chunk.files.forEach((file: string) => { - if (file.match(/\.js(\.map)?$/)) { - // remove js files - delete compilation.assets[file]; - } else { - newFiles.push(file); - } - }); - chunk.files = newFiles; - }); - }); - // Remove scripts tags with a css file as source, because HtmlWebpackPlugin will use - // a css file as a script for chunks without js files. - // TODO: Enable this once HtmlWebpackPlugin supports Webpack 4 - // compilation.plugin('html-webpack-plugin-alter-asset-tags', - // (htmlPluginData: any, callback: any) => { - // const filterFn = (tag: any) => - // !(tag.tagName === 'script' && tag.attributes.src.match(/\.css$/)); - // htmlPluginData.head = htmlPluginData.head.filter(filterFn); - // htmlPluginData.body = htmlPluginData.body.filter(filterFn); - // callback(null, htmlPluginData); - // }); - }); - } -} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/webpack.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/webpack.ts deleted file mode 100644 index 5679b0be0011..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/webpack.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @license - * 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 - */ - -// Exports the webpack plugins we use internally. -export { CleanCssWebpackPlugin, CleanCssWebpackPluginOptions } from './cleancss-webpack-plugin'; -export { BundleBudgetPlugin, BundleBudgetPluginOptions } from './bundle-budget'; -export { ScriptsWebpackPlugin, ScriptsWebpackPluginOptions } from './scripts-webpack-plugin'; -export { SuppressExtractedTextChunksWebpackPlugin } from './suppress-entry-chunks-webpack-plugin'; -export { RemoveHashPlugin, RemoveHashPluginOptions } from './remove-hash-plugin'; -export { - default as PostcssCliResources, - PostcssCliResourcesOptions, -} from './postcss-cli-resources'; - -import { join } from 'path'; -export const RawCssLoader = require.resolve(join(__dirname, 'raw-css-loader')); diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/bundle-calculator.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/bundle-calculator.ts deleted file mode 100644 index ca20d6fb386c..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/bundle-calculator.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * @license - * 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 - */ -import { Budget } from '../../browser/schema'; - -export interface Compilation { - assets: { [name: string]: { size: () => number } }; - chunks: { name: string, files: string[], isOnlyInitial: () => boolean }[]; - warnings: string[]; - errors: string[]; -} - -export interface Size { - size: number; - label?: string; -} - -export function calculateSizes(budget: Budget, compilation: Compilation): Size[] { - const calculatorMap = { - all: AllCalculator, - allScript: AllScriptCalculator, - any: AnyCalculator, - anyScript: AnyScriptCalculator, - bundle: BundleCalculator, - initial: InitialCalculator, - }; - const ctor = calculatorMap[budget.type]; - const calculator = new ctor(budget, compilation); - - return calculator.calculate(); -} - -export abstract class Calculator { - constructor (protected budget: Budget, protected compilation: Compilation) {} - - abstract calculate(): Size[]; -} - -/** - * A named bundle. - */ -class BundleCalculator extends Calculator { - calculate() { - const size: number = this.compilation.chunks - .filter(chunk => chunk.name === this.budget.name) - .reduce((files, chunk) => [...files, ...chunk.files], []) - .map((file: string) => this.compilation.assets[file].size()) - .reduce((total: number, size: number) => total + size, 0); - - return [{size, label: this.budget.name}]; - } -} - -/** - * The sum of all initial chunks (marked as initial by webpack). - */ -class InitialCalculator extends Calculator { - calculate() { - const initialChunks = this.compilation.chunks.filter(chunk => chunk.isOnlyInitial()); - const size: number = initialChunks - .reduce((files, chunk) => [...files, ...chunk.files], []) - .filter((file: string) => !file.endsWith('.map')) - .map((file: string) => this.compilation.assets[file].size()) - .reduce((total: number, size: number) => total + size, 0); - - return [{size, label: 'initial'}]; - } -} - -/** - * The sum of all the scripts portions. - */ -class AllScriptCalculator extends Calculator { - calculate() { - const size: number = Object.keys(this.compilation.assets) - .filter(key => key.endsWith('.js')) - .map(key => this.compilation.assets[key]) - .map(asset => asset.size()) - .reduce((total: number, size: number) => total + size, 0); - - return [{size, label: 'total scripts'}]; - } -} - -/** - * All scripts and assets added together. - */ -class AllCalculator extends Calculator { - calculate() { - const size: number = Object.keys(this.compilation.assets) - .filter(key => !key.endsWith('.map')) - .map(key => this.compilation.assets[key].size()) - .reduce((total: number, size: number) => total + size, 0); - - return [{size, label: 'total'}]; - } -} - -/** - * Any script, individually. - */ -class AnyScriptCalculator extends Calculator { - calculate() { - return Object.keys(this.compilation.assets) - .filter(key => key.endsWith('.js')) - .map(key => { - const asset = this.compilation.assets[key]; - - return { - size: asset.size(), - label: key, - }; - }); - } -} - -/** - * Any script or asset (images, css, etc). - */ -class AnyCalculator extends Calculator { - calculate() { - return Object.keys(this.compilation.assets) - .filter(key => !key.endsWith('.map')) - .map(key => { - const asset = this.compilation.assets[key]; - - return { - size: asset.size(), - label: key, - }; - }); - } -} - -/** - * Calculate the bytes given a string value. - */ -export function calculateBytes( - input: string, - baseline?: string, - factor: 1 | -1 = 1, -): number { - const matches = input.match(/^\s*(\d+(?:\.\d+)?)\s*(%|(?:[mM]|[kK]|[gG])?[bB])?\s*$/); - if (!matches) { - return NaN; - } - - const baselineBytes = baseline && calculateBytes(baseline) || 0; - - let value = Number(matches[1]); - switch (matches[2] && matches[2].toLowerCase()) { - case '%': - value = baselineBytes * value / 100 * factor; - break; - case 'kb': - value *= 1024; - break; - case 'mb': - value *= 1024 * 1024; - break; - case 'gb': - value *= 1024 * 1024 * 1024; - break; - } - - return value + baselineBytes; -} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/bundle-calculator_spec.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/bundle-calculator_spec.ts deleted file mode 100644 index c610706ebe0c..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/bundle-calculator_spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @license - * 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 - */ -import { calculateBytes } from './bundle-calculator'; - -describe('bundle-calculator', () => { - it('converts an integer with no postfix', () => { - expect(calculateBytes('0')).toBe(0); - expect(calculateBytes('5')).toBe(5); - expect(calculateBytes('190')).toBe(190); - expect(calculateBytes('92')).toBe(92); - }); - - it('converts a decimal with no postfix', () => { - expect(calculateBytes('3.14')).toBe(3.14); - expect(calculateBytes('0.25')).toBe(0.25); - expect(calculateBytes('90.5')).toBe(90.5); - expect(calculateBytes('25.0')).toBe(25); - }); - - it('converts an integer with kb postfix', () => { - expect(calculateBytes('0kb')).toBe(0); - expect(calculateBytes('5kb')).toBe(5 * 1024); - expect(calculateBytes('190KB')).toBe(190 * 1024); - expect(calculateBytes('92Kb')).toBe(92 * 1024); - expect(calculateBytes('25kB')).toBe(25 * 1024); - }); - - it('converts a decimal with kb postfix', () => { - expect(calculateBytes('3.14kb')).toBe(3.14 * 1024); - expect(calculateBytes('0.25KB')).toBe(0.25 * 1024); - expect(calculateBytes('90.5Kb')).toBe(90.5 * 1024); - expect(calculateBytes('25.0kB')).toBe(25 * 1024); - }); - - it('converts an integer with mb postfix', () => { - expect(calculateBytes('0mb')).toBe(0); - expect(calculateBytes('5mb')).toBe(5 * 1024 * 1024); - expect(calculateBytes('190MB')).toBe(190 * 1024 * 1024); - expect(calculateBytes('92Mb')).toBe(92 * 1024 * 1024); - expect(calculateBytes('25mB')).toBe(25 * 1024 * 1024); - }); - - it('converts a decimal with mb postfix', () => { - expect(calculateBytes('3.14mb')).toBe(3.14 * 1024 * 1024); - expect(calculateBytes('0.25MB')).toBe(0.25 * 1024 * 1024); - expect(calculateBytes('90.5Mb')).toBe(90.5 * 1024 * 1024); - expect(calculateBytes('25.0mB')).toBe(25 * 1024 * 1024); - }); - - it('converts an integer with gb postfix', () => { - expect(calculateBytes('0gb')).toBe(0); - expect(calculateBytes('5gb')).toBe(5 * 1024 * 1024 * 1024); - expect(calculateBytes('190GB')).toBe(190 * 1024 * 1024 * 1024); - expect(calculateBytes('92Gb')).toBe(92 * 1024 * 1024 * 1024); - expect(calculateBytes('25gB')).toBe(25 * 1024 * 1024 * 1024); - }); - - it('converts a decimal with gb postfix', () => { - expect(calculateBytes('3.14gb')).toBe(3.14 * 1024 * 1024 * 1024); - expect(calculateBytes('0.25GB')).toBe(0.25 * 1024 * 1024 * 1024); - expect(calculateBytes('90.5Gb')).toBe(90.5 * 1024 * 1024 * 1024); - expect(calculateBytes('25.0gB')).toBe(25 * 1024 * 1024 * 1024); - }); - - it ('converts a percentage with baseline', () => { - expect(calculateBytes('20%', '1mb')).toBe(1024 * 1024 * 1.2); - expect(calculateBytes('20%', '1mb', -1)).toBe(1024 * 1024 * 0.8); - }); - - it ('supports whitespace', () => { - expect(calculateBytes(' 5kb ')).toBe(5 * 1024); - expect(calculateBytes('0.25 MB')).toBe(0.25 * 1024 * 1024); - expect(calculateBytes(' 20 % ', ' 1 mb ')).toBe(1024 * 1024 * 1.2); - }); -}); diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/check-port.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/check-port.ts deleted file mode 100644 index 316dac751e02..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/check-port.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @license - * 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 - */ - -import { Observable } from 'rxjs'; -const portfinder = require('portfinder'); - - -export function checkPort(port: number, host: string, basePort = 49152): Observable { - return new Observable(obs => { - portfinder.basePort = basePort; - // tslint:disable:no-any - portfinder.getPort({ port, host }, (err: any, foundPort: number) => { - if (err) { - obs.error(err); - } else if (port !== foundPort && port !== 0) { - // If the port isn't available and we weren't looking for any port, throw error. - obs.error(`Port ${port} is already in use. Use '--port' to specify a different port.`); - } else { - // Otherwise, our found port is good. - obs.next(foundPort); - obs.complete(); - } - }); - }); -} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/find-up.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/find-up.ts deleted file mode 100644 index a57d2b64fccc..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/find-up.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable -// TODO: cleanup this file, it's copied as is from Angular CLI. - -import * as path from 'path'; -import { existsSync } from 'fs'; - -export function findUp(names: string | string[], from: string, stopOnNodeModules = false) { - if (!Array.isArray(names)) { - names = [names]; - } - const root = path.parse(from).root; - - let currentDir = from; - while (currentDir && currentDir !== root) { - for (const name of names) { - const p = path.join(currentDir, name); - if (existsSync(p)) { - return p; - } - } - - if (stopOnNodeModules) { - const nodeModuleP = path.join(currentDir, 'node_modules'); - if (existsSync(nodeModuleP)) { - return null; - } - } - - currentDir = path.dirname(currentDir); - } - - return null; -} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/is-directory.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/is-directory.ts deleted file mode 100644 index f4636a272531..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/is-directory.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable -// TODO: cleanup this file, it's copied as is from Angular CLI. - -import * as fs from 'fs'; - -export function isDirectory(path: string) { - try { - return fs.statSync(path).isDirectory(); - } catch (_) { - return false; - } -} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/package-chunk-sort.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/package-chunk-sort.ts deleted file mode 100644 index 2e443db69a22..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/package-chunk-sort.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @license - * 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 - */ -import { ExtraEntryPoint } from '../../browser/schema'; -import { normalizeExtraEntryPoints } from '../models/webpack-configs/utils'; - -export function generateEntryPoints( - appConfig: { styles: ExtraEntryPoint[], scripts: ExtraEntryPoint[] }, -) { - const entryPoints = ['polyfills', 'sw-register']; - - // Add all styles/scripts, except lazy-loaded ones. - [ - ...normalizeExtraEntryPoints(appConfig.styles, 'styles') - .filter(entry => !entry.lazy) - .map(entry => entry.bundleName), - ...normalizeExtraEntryPoints(appConfig.scripts, 'scripts') - .filter(entry => !entry.lazy) - .map(entry => entry.bundleName), - ].forEach(bundleName => { - if (entryPoints.indexOf(bundleName) === -1) { - entryPoints.push(bundleName); - } - }); - - entryPoints.push('main'); - - return entryPoints; -} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/read-tsconfig.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/read-tsconfig.ts deleted file mode 100644 index 52aff15e8901..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/read-tsconfig.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable -// TODO: cleanup this file, it's copied as is from Angular CLI. -import * as ts from 'typescript'; -import * as path from 'path'; -import { requireProjectModule } from '../utilities/require-project-module'; - -export function readTsconfig(tsconfigPath: string) { - const projectTs = requireProjectModule(path.dirname(tsconfigPath), 'typescript') as typeof ts; - const configResult = projectTs.readConfigFile(tsconfigPath, projectTs.sys.readFile); - const tsConfig = projectTs.parseJsonConfigFileContent(configResult.config, projectTs.sys, - path.dirname(tsconfigPath), undefined, tsconfigPath); - return tsConfig; -} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/require-project-module.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/require-project-module.ts deleted file mode 100644 index ec1eed116dca..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/require-project-module.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @license - * 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 - */ -import { resolve } from '@angular-devkit/core/node'; - -// Resolve dependencies within the target project. -export function resolveProjectModule(root: string, moduleName: string) { - return resolve( - moduleName, - { - basedir: root, - checkGlobal: false, - checkLocal: true, - }, - ); -} - -// Require dependencies within the target project. -export function requireProjectModule(root: string, moduleName: string) { - return require(resolveProjectModule(root, moduleName)); -} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/service-worker/index.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/service-worker/index.ts deleted file mode 100644 index 9ef70e5ac91a..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/service-worker/index.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * @license - * 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 - */ -// TODO: cleanup this file, it's copied as is from Angular CLI. -import { - Path, - dirname, - getSystemPath, - join, - normalize, - tags, - virtualFs, -} from '@angular-devkit/core'; -import { - Filesystem, - Generator, -} from '@angular/service-worker/config'; // tslint:disable-line:no-implicit-dependencies -import * as crypto from 'crypto'; -import * as fs from 'fs'; -import { Observable, from, merge, of } from 'rxjs'; -import { concatMap, map, mergeMap, reduce, switchMap, toArray } from 'rxjs/operators'; -import * as semver from 'semver'; -import { resolveProjectModule } from '../require-project-module'; - - -export const NEW_SW_VERSION = '5.0.0-rc.0'; - - -class CliFilesystem implements Filesystem { - constructor(private _host: virtualFs.Host, private base: string) { } - - list(path: string): Promise { - const recursiveList = (path: Path): Observable => this._host.list(path).pipe( - // Emit each fragment individually. - concatMap(fragments => from(fragments)), - // Join the path with fragment. - map(fragment => join(path, fragment)), - // Emit directory content paths instead of the directory path. - mergeMap(path => this._host.isDirectory(path).pipe( - concatMap(isDir => isDir ? recursiveList(path) : of(path)), - ), - ), - ); - - return recursiveList(this._resolve(path)).pipe( - map(path => path.replace(this.base, '')), - toArray(), - ).toPromise().then(x => x, _err => []); - } - - read(path: string): Promise { - return this._readIntoBuffer(path) - .then(content => virtualFs.fileBufferToString(content)); - } - - hash(path: string): Promise { - const sha1 = crypto.createHash('sha1'); - - return this._readIntoBuffer(path) - .then(content => sha1.update(Buffer.from(content))) - .then(() => sha1.digest('hex')); - } - - write(path: string, content: string): Promise { - return this._host.write(this._resolve(path), virtualFs.stringToFileBuffer(content)) - .toPromise(); - } - - private _readIntoBuffer(path: string): Promise { - return this._host.read(this._resolve(path)) - .toPromise(); - } - - private _resolve(path: string): Path { - return join(normalize(this.base), path); - } -} - -export function usesServiceWorker(projectRoot: string): boolean { - let swPackageJsonPath; - - try { - swPackageJsonPath = resolveProjectModule(projectRoot, '@angular/service-worker/package.json'); - } catch (_) { - // @angular/service-worker is not installed - throw new Error(tags.stripIndent` - Your project is configured with serviceWorker = true, but @angular/service-worker - is not installed. Run \`npm install --save-dev @angular/service-worker\` - and try again, or run \`ng set apps.0.serviceWorker=false\` in your .angular-cli.json. - `); - } - - const swPackageJson = fs.readFileSync(swPackageJsonPath).toString(); - const swVersion = JSON.parse(swPackageJson)['version']; - - if (!semver.gte(swVersion, NEW_SW_VERSION)) { - throw new Error(tags.stripIndent` - The installed version of @angular/service-worker is ${swVersion}. This version of the CLI - requires the @angular/service-worker version to satisfy ${NEW_SW_VERSION}. Please upgrade - your service worker version. - `); - } - - return true; -} - -export function augmentAppWithServiceWorker( - host: virtualFs.Host, - projectRoot: Path, - appRoot: Path, - outputPath: Path, - baseHref: string, - ngswConfigPath?: string, -): Promise { - // Path to the worker script itself. - const distPath = normalize(outputPath); - const workerPath = normalize( - resolveProjectModule(getSystemPath(projectRoot), '@angular/service-worker/ngsw-worker.js'), - ); - const swConfigPath = resolveProjectModule( - getSystemPath(projectRoot), - '@angular/service-worker/config', - ); - const safetyPath = join(dirname(workerPath), 'safety-worker.js'); - const configPath = ngswConfigPath as Path || join(appRoot, 'ngsw-config.json'); - - return host.exists(configPath).pipe( - switchMap(exists => { - if (!exists) { - throw new Error(tags.oneLine` - Error: Expected to find an ngsw-config.json configuration - file in the ${appRoot} folder. Either provide one or disable Service Worker - in your angular.json configuration file.`, - ); - } - - return host.read(configPath) as Observable; - }), - map(content => JSON.parse(virtualFs.fileBufferToString(content))), - switchMap(configJson => { - const GeneratorConstructor = require(swConfigPath).Generator as typeof Generator; - const gen = new GeneratorConstructor(new CliFilesystem(host, outputPath), baseHref); - - return gen.process(configJson); - }), - - switchMap(output => { - const manifest = JSON.stringify(output, null, 2); - - return host.read(workerPath).pipe( - switchMap(workerCode => { - return merge( - host.write(join(distPath, 'ngsw.json'), virtualFs.stringToFileBuffer(manifest)), - host.write(join(distPath, 'ngsw-worker.js'), workerCode), - ) as Observable; - }), - ); - }), - - switchMap(() => host.exists(safetyPath)), - // If @angular/service-worker has the safety script, copy it into two locations. - switchMap(exists => { - if (!exists) { - return of(undefined); - } - - return host.read(safetyPath).pipe( - switchMap(safetyCode => { - return merge( - host.write(join(distPath, 'worker-basic.min.js'), safetyCode), - host.write(join(distPath, 'safety-worker.js'), safetyCode), - ) as Observable; - }), - ); - }), - - // Remove all elements, reduce them to a single emit. - reduce(() => {}), - ).toPromise(); -} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/stats.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/stats.ts deleted file mode 100644 index ebe40dd73a99..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/stats.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable -// TODO: cleanup this file, it's copied as is from Angular CLI. -import { tags, terminal } from '@angular-devkit/core'; - - -const { bold, green, red, reset, white, yellow } = terminal; - -export function formatSize(size: number): string { - if (size <= 0) { - return '0 bytes'; - } - - const abbreviations = ['bytes', 'kB', 'MB', 'GB']; - const index = Math.floor(Math.log(size) / Math.log(1000)); - - return `${+(size / Math.pow(1000, index)).toPrecision(3)} ${abbreviations[index]}`; -} - - -export function statsToString(json: any, statsConfig: any) { - const colors = statsConfig.colors; - const rs = (x: string) => colors ? reset(x) : x; - const w = (x: string) => colors ? bold(white(x)) : x; - const g = (x: string) => colors ? bold(green(x)) : x; - const y = (x: string) => colors ? bold(yellow(x)) : x; - - const changedChunksStats = json.chunks - .filter((chunk: any) => chunk.rendered) - .map((chunk: any) => { - const asset = json.assets.filter((x: any) => x.name == chunk.files[0])[0]; - const size = asset ? ` ${formatSize(asset.size)}` : ''; - const files = chunk.files.join(', '); - const names = chunk.names ? ` (${chunk.names.join(', ')})` : ''; - const initial = y(chunk.entry ? '[entry]' : chunk.initial ? '[initial]' : ''); - const flags = ['rendered', 'recorded'] - .map(f => f && chunk[f] ? g(` [${f}]`) : '') - .join(''); - - return `chunk {${y(chunk.id)}} ${g(files)}${names}${size} ${initial}${flags}`; - }); - - const unchangedChunkNumber = json.chunks.length - changedChunksStats.length; - - if (unchangedChunkNumber > 0) { - return '\n' + rs(tags.stripIndents` - Date: ${w(new Date().toISOString())} - Hash: ${w(json.hash)} - Time: ${w('' + json.time)}ms - ${unchangedChunkNumber} unchanged chunks - ${changedChunksStats.join('\n')} - `); - } else { - return '\n' + rs(tags.stripIndents` - Date: ${w(new Date().toISOString())} - Hash: ${w(json.hash)} - Time: ${w('' + json.time)}ms - ${changedChunksStats.join('\n')} - `); - } -} - -export function statsWarningsToString(json: any, statsConfig: any) { - const colors = statsConfig.colors; - const rs = (x: string) => colors ? reset(x) : x; - const y = (x: string) => colors ? bold(yellow(x)) : x; - - return rs('\n' + json.warnings.map((warning: any) => y(`WARNING in ${warning}`)).join('\n\n')); -} - -export function statsErrorsToString(json: any, statsConfig: any) { - const colors = statsConfig.colors; - const rs = (x: string) => colors ? reset(x) : x; - const r = (x: string) => colors ? bold(red(x)) : x; - - return rs('\n' + json.errors.map((error: any) => r(`ERROR in ${error}`)).join('\n')); -} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/strip-bom.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/strip-bom.ts deleted file mode 100644 index f4aba38228b3..000000000000 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/strip-bom.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable -// TODO: cleanup this file, it's copied as is from Angular CLI. - -// Strip BOM from file data. -// https://stackoverflow.com/questions/24356713 -export function stripBom(data: string) { - return data.replace(/^\uFEFF/, ''); -} diff --git a/packages/angular_devkit/build_angular/src/app-shell/index.ts b/packages/angular_devkit/build_angular/src/app-shell/index.ts deleted file mode 100644 index 2d1bc1cdef92..000000000000 --- a/packages/angular_devkit/build_angular/src/app-shell/index.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * @license - * 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 - */ -import { - BuildEvent, - Builder, - BuilderConfiguration, - BuilderContext, -} from '@angular-devkit/architect'; -import { Path, getSystemPath, join, normalize, resolve, virtualFs } from '@angular-devkit/core'; -import { Observable, forkJoin, from, merge, of, throwError } from 'rxjs'; -import { concatMap, map, switchMap } from 'rxjs/operators'; -import { requireProjectModule } from '../angular-cli-files/utilities/require-project-module'; -import { augmentAppWithServiceWorker } from '../angular-cli-files/utilities/service-worker'; -import { BrowserBuilderSchema } from '../browser/schema'; -import { BuildWebpackServerSchema } from '../server/schema'; -import { BuildWebpackAppShellSchema } from './schema'; - - -export class AppShellBuilder implements Builder { - - constructor(public context: BuilderContext) { } - - run(builderConfig: BuilderConfiguration): Observable { - const options = builderConfig.options; - - return new Observable(obs => { - let success = true; - const subscription = merge( - this.build(options.serverTarget, {}), - // Never run the browser target in watch mode. - // If service worker is needed, it will be added in this.renderUniversal(); - this.build(options.browserTarget, { watch: false, serviceWorker: false }), - ).subscribe((event: BuildEvent) => { - // TODO: once we support a better build event, add support for merging two event streams - // together. - success = success && event.success; - }, error => { - obs.error(error); - }, () => { - obs.next({ success }); - obs.complete(); - }); - - // Allow subscriptions to us to unsubscribe from each builds at the same time. - return () => subscription.unsubscribe(); - }).pipe( - switchMap(event => { - if (!event.success) { - return of(event); - } - - return this.renderUniversal(options); - }), - ); - } - - build(targetString: string, overrides: {}) { - const architect = this.context.architect; - const [project, target, configuration] = targetString.split(':'); - - // Override browser build watch setting. - const builderConfig = architect.getBuilderConfiguration<{}>({ - project, - target, - configuration, - overrides, - }); - - return architect.run(builderConfig, this.context); - } - - getServerModuleBundlePath(options: BuildWebpackAppShellSchema) { - const architect = this.context.architect; - - return new Observable(obs => { - if (options.appModuleBundle) { - obs.next(join(this.context.workspace.root, options.appModuleBundle)); - - return obs.complete(); - } else { - const [project, target, configuration] = options.serverTarget.split(':'); - const builderConfig = architect.getBuilderConfiguration({ - project, - target, - configuration, - }); - - return architect.getBuilderDescription(builderConfig).pipe( - concatMap(description => architect.validateBuilderOptions(builderConfig, description)), - switchMap(config => { - const outputPath = join(this.context.workspace.root, config.options.outputPath); - - return this.context.host.list(outputPath).pipe( - switchMap(files => { - const re = /^main\.(?:[a-zA-Z0-9]{20}\.)?(?:bundle\.)?js$/; - const maybeMain = files.filter(x => re.test(x))[0]; - - if (!maybeMain) { - return throwError(new Error('Could not find the main bundle.')); - } else { - return of(join(outputPath, maybeMain)); - } - }), - ); - }), - ).subscribe(obs); - } - }); - } - - getBrowserBuilderConfig(options: BuildWebpackAppShellSchema) { - const architect = this.context.architect; - const [project, target, configuration] = options.browserTarget.split(':'); - const builderConfig = architect.getBuilderConfiguration({ - project, - target, - configuration, - }); - - return architect.getBuilderDescription(builderConfig).pipe( - concatMap(description => architect.validateBuilderOptions(builderConfig, description)), - ); - } - - renderUniversal(options: BuildWebpackAppShellSchema): Observable { - let browserOptions: BrowserBuilderSchema; - let projectRoot: Path; - - return forkJoin( - this.getBrowserBuilderConfig(options).pipe( - switchMap(config => { - browserOptions = config.options; - projectRoot = resolve(this.context.workspace.root, config.root); - const browserIndexOutputPath = join(normalize(browserOptions.outputPath), 'index.html'); - const path = join(this.context.workspace.root, browserIndexOutputPath); - - return this.context.host.read(path).pipe( - map(x => { - return [browserIndexOutputPath, x]; - }), - ); - }), - ), - this.getServerModuleBundlePath(options), - ).pipe( - switchMap(([[browserIndexOutputPath, indexContent], serverBundlePath]) => { - const root = this.context.workspace.root; - requireProjectModule(getSystemPath(root), 'zone.js/dist/zone-node'); - - const renderModuleFactory = requireProjectModule( - getSystemPath(root), - '@angular/platform-server', - ).renderModuleFactory; - const AppServerModuleNgFactory = require( - getSystemPath(serverBundlePath), - ).AppServerModuleNgFactory; - const indexHtml = virtualFs.fileBufferToString(indexContent); - const outputIndexPath = join(root, options.outputIndexPath || browserIndexOutputPath); - - // Render to HTML and overwrite the client index file. - return from( - renderModuleFactory(AppServerModuleNgFactory, { - document: indexHtml, - url: options.route, - }) - .then(async (html: string) => { - await this.context.host - .write(outputIndexPath, virtualFs.stringToFileBuffer(html)) - .toPromise(); - - if (browserOptions.serviceWorker) { - await augmentAppWithServiceWorker( - this.context.host, - root, - projectRoot, - join(root, browserOptions.outputPath), - browserOptions.baseHref || '/', - browserOptions.ngswConfigPath, - ); - } - - return { success: true }; - }), - ); - }), - ); - } -} - -export default AppShellBuilder; diff --git a/packages/angular_devkit/build_angular/src/app-shell/schema.d.ts b/packages/angular_devkit/build_angular/src/app-shell/schema.d.ts deleted file mode 100644 index b7f0b1d1de37..000000000000 --- a/packages/angular_devkit/build_angular/src/app-shell/schema.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * @license - * 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 - */ -export interface BuildWebpackAppShellSchema { - browserTarget: string; - serverTarget: string; - appModuleBundle?: string; - route: string; - inputIndexPath?: string; - outputIndexPath?: string; -} diff --git a/packages/angular_devkit/build_angular/src/app-shell/schema.json b/packages/angular_devkit/build_angular/src/app-shell/schema.json deleted file mode 100644 index 4e246a6f8360..000000000000 --- a/packages/angular_devkit/build_angular/src/app-shell/schema.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "title": "App Shell Target", - "description": "App Shell target options for Build Facade.", - "type": "object", - "properties": { - "browserTarget": { - "type": "string", - "description": "Target to build." - }, - "serverTarget": { - "type": "string", - "description": "Server target to use for rendering the app shell." - }, - "appModuleBundle": { - "type": "string", - "description": "Script that exports the Server AppModule to render. This should be the main JavaScript outputted by the server target. By default we will resolve the outputPath of the serverTarget and find a bundle named 'main' in it (whether or not there's a hash tag)." - }, - "route": { - "type": "string", - "description": "The route to render.", - "default": "/" - }, - "inputIndexPath": { - "type": "string", - "description": "The input path for the index.html file. By default uses the output index.html of the browser target." - }, - "outputIndexPath": { - "type": "string", - "description": "The output path of the index.html file. By default will overwrite the input file." - } - }, - "additionalProperties": false, - "required": [ - "browserTarget", - "serverTarget" - ] -} diff --git a/packages/angular_devkit/build_angular/src/browser/index.ts b/packages/angular_devkit/build_angular/src/browser/index.ts deleted file mode 100644 index ab49981a9f0c..000000000000 --- a/packages/angular_devkit/build_angular/src/browser/index.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * @license - * 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 - */ -import { - BuildEvent, - Builder, - BuilderConfiguration, - BuilderContext, -} from '@angular-devkit/architect'; -import { LoggingCallback, WebpackBuilder } from '@angular-devkit/build-webpack'; -import { Path, getSystemPath, join, normalize, resolve, virtualFs } from '@angular-devkit/core'; -import * as fs from 'fs'; -import { Observable, concat, of, throwError } from 'rxjs'; -import { concatMap, last, tap } from 'rxjs/operators'; -import * as ts from 'typescript'; // tslint:disable-line:no-implicit-dependencies -import { WebpackConfigOptions } from '../angular-cli-files/models/build-options'; -import { - getAotConfig, - getBrowserConfig, - getCommonConfig, - getNonAotConfig, - getStatsConfig, - getStylesConfig, -} from '../angular-cli-files/models/webpack-configs'; -import { readTsconfig } from '../angular-cli-files/utilities/read-tsconfig'; -import { requireProjectModule } from '../angular-cli-files/utilities/require-project-module'; -import { augmentAppWithServiceWorker } from '../angular-cli-files/utilities/service-worker'; -import { - statsErrorsToString, - statsToString, - statsWarningsToString, -} from '../angular-cli-files/utilities/stats'; -import { defaultProgress, normalizeAssetPatterns, normalizeFileReplacements } from '../utils'; -import { AssetPatternObject, BrowserBuilderSchema, CurrentFileReplacement } from './schema'; -const SpeedMeasurePlugin = require('speed-measure-webpack-plugin'); -const webpackMerge = require('webpack-merge'); - - -// TODO: figure out a better way to normalize assets, extra entry points, file replacements, -// and whatever else needs to be normalized, while keeping type safety. -// Right now this normalization has to be done in all other builders that make use of the -// BrowserBuildSchema and BrowserBuilder.buildWebpackConfig. -// It would really help if it happens during architect.validateBuilderOptions, or similar. -export interface NormalizedBrowserBuilderSchema extends BrowserBuilderSchema { - assets: AssetPatternObject[]; - fileReplacements: CurrentFileReplacement[]; -} - -export class BrowserBuilder implements Builder { - - constructor(public context: BuilderContext) { } - - run(builderConfig: BuilderConfiguration): Observable { - const options = builderConfig.options; - const root = this.context.workspace.root; - const projectRoot = resolve(root, builderConfig.root); - const host = new virtualFs.AliasHost(this.context.host as virtualFs.Host); - const webpackBuilder = new WebpackBuilder({ ...this.context, host }); - - return of(null).pipe( - concatMap(() => options.deleteOutputPath - ? this._deleteOutputDir(root, normalize(options.outputPath), this.context.host) - : of(null)), - concatMap(() => normalizeFileReplacements(options.fileReplacements, host, root)), - tap(fileReplacements => options.fileReplacements = fileReplacements), - concatMap(() => normalizeAssetPatterns( - options.assets, host, root, projectRoot, builderConfig.sourceRoot)), - // Replace the assets in options with the normalized version. - tap((assetPatternObjects => options.assets = assetPatternObjects)), - concatMap(() => { - let webpackConfig; - try { - webpackConfig = this.buildWebpackConfig(root, projectRoot, host, - options as NormalizedBrowserBuilderSchema); - } catch (e) { - return throwError(e); - } - - return webpackBuilder.runWebpack(webpackConfig, getBrowserLoggingCb(options.verbose)); - }), - concatMap(buildEvent => { - if (buildEvent.success && !options.watch && options.serviceWorker) { - return new Observable(obs => { - augmentAppWithServiceWorker( - this.context.host, - root, - projectRoot, - resolve(root, normalize(options.outputPath)), - options.baseHref || '/', - options.ngswConfigPath, - ).then( - () => { - obs.next({ success: true }); - obs.complete(); - }, - (err: Error) => { - obs.error(err); - }, - ); - }); - } else { - return of(buildEvent); - } - }), - ); - } - - buildWebpackConfig( - root: Path, - projectRoot: Path, - host: virtualFs.Host, - options: NormalizedBrowserBuilderSchema, - ) { - // Ensure Build Optimizer is only used with AOT. - if (options.buildOptimizer && !options.aot) { - throw new Error('The `--build-optimizer` option cannot be used without `--aot`.'); - } - - let wco: WebpackConfigOptions; - - const tsConfigPath = getSystemPath(normalize(resolve(root, normalize(options.tsConfig)))); - const tsConfig = readTsconfig(tsConfigPath); - - const projectTs = requireProjectModule(getSystemPath(projectRoot), 'typescript') as typeof ts; - - const supportES2015 = tsConfig.options.target !== projectTs.ScriptTarget.ES3 - && tsConfig.options.target !== projectTs.ScriptTarget.ES5; - - wco = { - root: getSystemPath(root), - logger: this.context.logger, - projectRoot: getSystemPath(projectRoot), - buildOptions: options, - tsConfig, - tsConfigPath, - supportES2015, - }; - - wco.buildOptions.progress = defaultProgress(wco.buildOptions.progress); - - const webpackConfigs: {}[] = [ - getCommonConfig(wco), - getBrowserConfig(wco), - getStylesConfig(wco), - getStatsConfig(wco), - ]; - - if (wco.buildOptions.main || wco.buildOptions.polyfills) { - const typescriptConfigPartial = wco.buildOptions.aot - ? getAotConfig(wco, host) - : getNonAotConfig(wco, host); - webpackConfigs.push(typescriptConfigPartial); - } - - const webpackConfig = webpackMerge(webpackConfigs); - - if (options.profile) { - const smp = new SpeedMeasurePlugin({ - outputFormat: 'json', - outputTarget: getSystemPath(join(root, 'speed-measure-plugin.json')), - }); - - return smp.wrap(webpackConfig); - } - - return webpackConfig; - } - - private _deleteOutputDir(root: Path, outputPath: Path, host: virtualFs.Host) { - const resolvedOutputPath = resolve(root, outputPath); - if (resolvedOutputPath === root) { - throw new Error('Output path MUST not be project root directory!'); - } - - return host.exists(resolvedOutputPath).pipe( - concatMap(exists => exists - // TODO: remove this concat once host ops emit an event. - ? concat(host.delete(resolvedOutputPath), of(null)).pipe(last()) - // ? of(null) - : of(null)), - ); - } -} - -export const getBrowserLoggingCb = (verbose: boolean): LoggingCallback => - (stats, config, logger) => { - // config.stats contains our own stats settings, added during buildWebpackConfig(). - const json = stats.toJson(config.stats); - if (verbose) { - logger.info(stats.toString(config.stats)); - } else { - logger.info(statsToString(json, config.stats)); - } - - if (stats.hasWarnings()) { - logger.warn(statsWarningsToString(json, config.stats)); - } - if (stats.hasErrors()) { - logger.error(statsErrorsToString(json, config.stats)); - } - }; - -export default BrowserBuilder; diff --git a/packages/angular_devkit/build_angular/src/browser/schema.d.ts b/packages/angular_devkit/build_angular/src/browser/schema.d.ts deleted file mode 100644 index 5b66793292ad..000000000000 --- a/packages/angular_devkit/build_angular/src/browser/schema.d.ts +++ /dev/null @@ -1,375 +0,0 @@ -/** - * @license - * 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 - */ -export interface BrowserBuilderSchema { - /** - * List of static application assets. - */ - assets: AssetPattern[]; - - /** - * The name of the main entry-point file. - */ - main: string; - - /** - * The name of the polyfills file. - */ - polyfills?: string; - - /** - * The name of the TypeScript configuration file. - */ - tsConfig: string; - - /** - * Global scripts to be included in the build. - */ - scripts: ExtraEntryPoint[]; - - /** - * Global styles to be included in the build. - */ - styles: ExtraEntryPoint[]; - - /** - * Options to pass to style preprocessors. - */ - stylePreprocessorOptions?: StylePreprocessorOptions; - - /** - * Enables optimization of the build output. - */ - optimization: boolean; - - /** - * Replace files with other files in the build. - */ - fileReplacements: FileReplacement[]; - - /** - * Path where output will be placed. - */ - outputPath: string; - - /** - * Build using Ahead of Time compilation. - */ - aot: boolean; - - /** - * Output sourcemaps. - */ - sourceMap: boolean; - - /** - * Resolve vendor packages sourcemaps. - */ - vendorSourceMap?: boolean; - - /** - * Output in-file eval sourcemaps. - */ - evalSourceMap: boolean; - - /** - * Use a separate bundle containing only vendor libraries. - */ - vendorChunk: boolean; - - /** - * Use a separate bundle containing code used across multiple bundles. - */ - commonChunk: boolean; - - /** - * Base url for the application being built. - */ - baseHref?: string; - - /** - * URL where files will be deployed. - */ - deployUrl?: string; - - /** - * Adds more details to output logging. - */ - verbose: boolean; - - /** - * Log progress to the console while building. - */ - progress?: boolean; - - /** - * Localization file to use for i18n. - */ - i18nFile?: string; - - /** - * Format of the localization file specified with --i18n-file. - */ - i18nFormat?: string; - - /** - * Locale to use for i18n. - */ - i18nLocale?: string; - - /** - * How to handle missing translations for i18n. - */ - i18nMissingTranslation?: string; - - /** - * Extract css from global styles onto css files instead of js ones. - */ - extractCss: boolean; - - /** - * Run build when files change. - */ - watch: boolean; - - /** - * Define the output filename cache-busting hashing mode. - */ - outputHashing: OutputHashing; - - /** - * Enable and define the file watching poll time period in milliseconds. - */ - poll?: number; - - /** - * Delete the output path before building. - */ - deleteOutputPath: boolean; - - /** - * Do not use the real path when resolving modules. - */ - preserveSymlinks: boolean; - - /** - * Extract all licenses in a separate file, in the case of production builds only. - */ - extractLicenses: boolean; - - /** - * Show circular dependency warnings on builds. - */ - showCircularDependencies: boolean; - - /** - * Enables @angular-devkit/build-optimizer optimizations when using the 'aot' option. - */ - buildOptimizer: boolean; - - /** - * Use file name for lazy loaded chunks. - */ - namedChunks: boolean; - - /** - * Enables the use of subresource integrity validation. - */ - subresourceIntegrity: boolean; - - /** - * Generates a service worker config for production builds. - */ - serviceWorker: boolean; - - /** - * Path to ngsw-config.json. - */ - ngswConfigPath?: string; - - /** - * Flag to prevent building an app shell. - */ - skipAppShell: boolean; - - /** - * The name of the index HTML file. - */ - index: string; - - /** - * Generates a 'stats.json' file which can be analyzed using tools - * such as: #webpack-bundle-analyzer' or https: //webpack.github.io/analyse. - */ - statsJson: boolean; - - /** - * Run the TypeScript type checker in a forked process. - */ - forkTypeChecker: boolean; - - /** - * List of additional NgModule files that will be lazy loaded. - * Lazy router modules with be discovered automatically. - */ - lazyModules: string[]; - - /** - * Budget thresholds to ensure parts of your application stay within boundaries which you set. - */ - budgets: Budget[]; - - /** - * Output profile events for Chrome profiler. - */ - profile: boolean; -} - -export type AssetPattern = string | AssetPatternObject; - -export interface AssetPatternObject { - /** - * The pattern to match. - */ - glob: string; - - /** - * The input path dir in which to apply 'glob'. Defaults to the project root. - */ - input: string; - - /** - * An array of globs to ignore. - */ - ignore?: string[]; - - /** - * Absolute path within the output. - */ - output: string; -} - -export type ExtraEntryPoint = string | ExtraEntryPointObject; - -export interface ExtraEntryPointObject { - /** - * The file to include. - */ - input: string; - - /** - * The bundle name for this extra entry point. - */ - bundleName?: string; - - /** - * If the bundle will be lazy loaded. - */ - lazy: boolean; -} - -export declare type FileReplacement = DeprecatedFileReplacment | CurrentFileReplacement; - -export interface DeprecatedFileReplacment { - /** - * The file that should be replaced. - */ - src: string; - - /** - * The file that should replace. - */ - replaceWith: string; -} - -export interface CurrentFileReplacement { - /** - * The file that should be replaced. - */ - replace: string; - - /** - * The file that should replace. - */ - with: string; -} - -/** - * Define the output filename cache-busting hashing mode. - */ -export enum OutputHashing { - All = 'all', - Bundles = 'bundles', - Media = 'media', - None = 'none', -} - -/** - * Options to pass to style preprocessors - */ -export interface StylePreprocessorOptions { - /** - * Paths to include. Paths will be resolved to project root. - */ - includePaths: string[]; -} - -export interface Budget { - /** - * The type of budget. - */ - type: BudgetType; - - /** - * The name of the bundle. - */ - name: string; - - /** - * The baseline size for comparison. - */ - baseline: string; - - /** - * The maximum threshold for warning relative to the baseline. - */ - maximumWarning: string; - - /** - * The maximum threshold for error relative to the baseline. - */ - maximumError: string; - - /** - * The minimum threshold for warning relative to the baseline. - */ - minimumWarning: string; - - /** - * The minimum threshold for error relative to the baseline. - */ - minimumError: string; - - /** - * The threshold for warning relative to the baseline (min & max). - */ - warning: string; - - /** - * The threshold for error relative to the baseline (min & max). - */ - error: string; -} - -export enum BudgetType { - Initial = 'initial', - All = 'all', - Any = 'any', - AllScript = 'allScript', - AnyScript = 'anyScript', - Bundle = 'bundle', -} diff --git a/packages/angular_devkit/build_angular/src/browser/schema.json b/packages/angular_devkit/build_angular/src/browser/schema.json deleted file mode 100644 index ca7c2c0103b9..000000000000 --- a/packages/angular_devkit/build_angular/src/browser/schema.json +++ /dev/null @@ -1,412 +0,0 @@ -{ - "title": "Webpack browser schema for Build Facade.", - "description": "Browser target options", - "properties": { - "assets": { - "type": "array", - "description": "List of static application assets.", - "default": [], - "items": { - "$ref": "#/definitions/assetPattern" - } - }, - "main": { - "type": "string", - "description": "The full path for the main entry point to the app, relative to the current workspace.", - "$valueDescription": "fileName" - }, - "polyfills": { - "type": "string", - "description": "The full path for the polyfills file, relative to the current workspace." - }, - "tsConfig": { - "type": "string", - "description": "The full path for the TypeScript configuration file, relative to the current workspace." - }, - "scripts": { - "description": "Global scripts to be included in the build.", - "type": "array", - "default": [], - "items": { - "$ref": "#/definitions/extraEntryPoint" - } - }, - "styles": { - "description": "Global styles to be included in the build.", - "type": "array", - "default": [], - "items": { - "$ref": "#/definitions/extraEntryPoint" - } - }, - "stylePreprocessorOptions": { - "description": "Options to pass to style preprocessors.", - "type": "object", - "properties": { - "includePaths": { - "description": "Paths to include. Paths will be resolved to project root.", - "type": "array", - "items": { - "type": "string" - }, - "default": [] - } - }, - "additionalProperties": false - }, - "optimization": { - "type": "boolean", - "description": "When true, uses optimization for the app build.", - "default": false - }, - "fileReplacements": { - "description": "Replace files with other files in the build.", - "type": "array", - "items": { - "$ref": "#/definitions/fileReplacement" - }, - "default": [] - }, - "outputPath": { - "type": "string", - "description": "The full path for the new output directory, relative to the current workspace.\n\nBy default, writes output to a folder named dist/ in the current project." - }, - "aot": { - "type": "boolean", - "description": "Build using Ahead of Time compilation.", - "default": false - }, - "sourceMap": { - "type": "boolean", - "description": "Output sourcemaps.", - "default": true - }, - "vendorSourceMap": { - "type": "boolean", - "description": "Resolve vendor packages sourcemaps.", - "default": false - }, - "evalSourceMap": { - "type": "boolean", - "description": "Output in-file eval sourcemaps.", - "default": false - }, - "vendorChunk": { - "type": "boolean", - "description": "Use a separate bundle containing only vendor libraries.", - "default": true - }, - "commonChunk": { - "type": "boolean", - "description": "Use a separate bundle containing code used across multiple bundles.", - "default": true - }, - "baseHref": { - "type": "string", - "description": "Base url for the application being built." - }, - "deployUrl": { - "type": "string", - "description": "URL where files will be deployed." - }, - "verbose": { - "type": "boolean", - "description": "Adds more details to output logging.", - "default": false - }, - "progress": { - "type": "boolean", - "description": "Log progress to the console while building." - }, - "i18nFile": { - "type": "string", - "description": "Localization file to use for i18n." - }, - "i18nFormat": { - "type": "string", - "description": "Format of the localization file specified with --i18n-file." - }, - "i18nLocale": { - "type": "string", - "description": "Locale to use for i18n." - }, - "i18nMissingTranslation": { - "type": "string", - "description": "How to handle missing translations for i18n." - }, - "extractCss": { - "type": "boolean", - "description": "Extract css from global styles into css files instead of js ones.", - "default": false - }, - "watch": { - "type": "boolean", - "description": "Run build when files change.", - "default": false - }, - "outputHashing": { - "type": "string", - "description": "Define the output filename cache-busting hashing mode.", - "default": "none", - "enum": [ - "none", - "all", - "media", - "bundles" - ] - }, - "poll": { - "type": "number", - "description": "Enable and define the file watching poll time period in milliseconds." - }, - "deleteOutputPath": { - "type": "boolean", - "description": "Delete the output path before building.", - "default": true - }, - "preserveSymlinks": { - "type": "boolean", - "description": "Do not use the real path when resolving modules.", - "default": false - }, - "extractLicenses": { - "type": "boolean", - "description": "Extract all licenses in a separate file.", - "default": false - }, - "showCircularDependencies": { - "type": "boolean", - "description": "Show circular dependency warnings on builds.", - "default": true - }, - "buildOptimizer": { - "type": "boolean", - "description": "Enables '@angular-devkit/build-optimizer' optimizations when using the 'aot' option.", - "default": false - }, - "namedChunks": { - "type": "boolean", - "description": "Use file name for lazy loaded chunks.", - "default": true - }, - "subresourceIntegrity": { - "type": "boolean", - "description": "Enables the use of subresource integrity validation.", - "default": false - }, - "serviceWorker": { - "type": "boolean", - "description": "Generates a service worker config for production builds.", - "default": false - }, - "ngswConfigPath": { - "type": "string", - "description": "Path to ngsw-config.json." - }, - "skipAppShell": { - "type": "boolean", - "description": "Flag to prevent building an app shell.", - "default": false - }, - "index": { - "type": "string", - "description": "The name of the index HTML file." - }, - "statsJson": { - "type": "boolean", - "description": "Generates a 'stats.json' file which can be analyzed using tools such as: 'webpack-bundle-analyzer' or https: //webpack.github.io/analyse.", - "default": false - }, - "forkTypeChecker": { - "type": "boolean", - "description": "Run the TypeScript type checker in a forked process.", - "default": true - }, - "lazyModules": { - "description": "List of additional NgModule files that will be lazy loaded. Lazy router modules will be discovered automatically.", - "type": "array", - "items": { - "type": "string" - }, - "default": [] - }, - "budgets": { - "description": "Budget thresholds to ensure parts of your application stay within boundaries which you set.", - "type": "array", - "items": { - "$ref": "#/definitions/budget" - }, - "default": [] - }, - "profile": { - "type": "boolean", - "description": "Output profile events for Chrome profiler.", - "default": false - } - }, - "additionalProperties": false, - "required": [ - "outputPath", - "index", - "main", - "tsConfig" - ], - "definitions": { - "assetPattern": { - "oneOf": [ - { - "type": "object", - "properties": { - "glob": { - "type": "string", - "description": "The pattern to match." - }, - "input": { - "type": "string", - "description": "The input directory path in which to apply 'glob'. Defaults to the project root." - }, - "ignore": { - "description": "An array of globs to ignore.", - "type": "array", - "items": { - "type": "string" - } - }, - "output": { - "type": "string", - "description": "Absolute path within the output." - } - }, - "additionalProperties": false, - "required": [ - "glob", - "input", - "output" - ] - }, - { - "type": "string" - } - ] - }, - "fileReplacement": { - "oneOf": [ - { - "type": "object", - "properties": { - "src": { - "type": "string" - }, - "replaceWith": { - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "src", - "replaceWith" - ] - }, - { - "type": "object", - "properties": { - "replace": { - "type": "string" - }, - "with": { - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "replace", - "with" - ] - } - ] - }, - "extraEntryPoint": { - "oneOf": [ - { - "type": "object", - "properties": { - "input": { - "type": "string", - "description": "The file to include." - }, - "bundleName": { - "type": "string", - "description": "The bundle name for this extra entry point." - }, - "lazy": { - "type": "boolean", - "description": "If the bundle will be lazy loaded.", - "default": false - } - }, - "additionalProperties": false, - "required": [ - "input" - ] - }, - { - "type": "string", - "description": "The file to include." - } - ] - }, - "budget": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "The type of budget.", - "enum": [ - "all", - "allScript", - "any", - "anyScript", - "bundle", - "initial" - ] - }, - "name": { - "type": "string", - "description": "The name of the bundle." - }, - "baseline": { - "type": "string", - "description": "The baseline size for comparison." - }, - "maximumWarning": { - "type": "string", - "description": "The maximum threshold for warning relative to the baseline." - }, - "maximumError": { - "type": "string", - "description": "The maximum threshold for error relative to the baseline." - }, - "minimumWarning": { - "type": "string", - "description": "The minimum threshold for warning relative to the baseline." - }, - "minimumError": { - "type": "string", - "description": "The minimum threshold for error relative to the baseline." - }, - "warning": { - "type": "string", - "description": "The threshold for warning relative to the baseline (min & max)." - }, - "error": { - "type": "string", - "description": "The threshold for error relative to the baseline (min & max)." - } - }, - "additionalProperties": false, - "required": [ - "type" - ] - } - } -} diff --git a/packages/angular_devkit/build_angular/src/builders/app-shell/app-shell_spec.ts b/packages/angular_devkit/build_angular/src/builders/app-shell/app-shell_spec.ts new file mode 100644 index 000000000000..f39d8da34114 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/app-shell/app-shell_spec.ts @@ -0,0 +1,201 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { normalize } from '@angular-devkit/core'; +import { createArchitect, host } from '../../testing/test-utils'; + +describe('AppShell Builder', () => { + const target = { project: 'app', target: 'app-shell' }; + let architect: Architect; + const originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + + beforeAll(() => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 100_000; + }); + + afterAll(() => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + }); + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + afterEach(async () => host.restore().toPromise()); + + const appShellRouteFiles = { + 'src/styles.css': ` + p { color: #000 } + `, + 'src/app/app-shell/app-shell.component.html': ` +

+ app-shell works! +

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

Hello {{ title }}!

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

{{title}}

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

Login page

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

Login page

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

Login page

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

Login page

'); + }, + ); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/build-base-href_spec.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/build-base-href_spec.ts new file mode 100644 index 000000000000..813796079b17 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/build-base-href_spec.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { executeDevServer } from '../../index'; +import { executeOnceAndFetch } from '../execute-fetch'; +import { describeServeBuilder } from '../jasmine-helpers'; +import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup'; + +describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget) => { + describe('Behavior: "buildTarget baseHref"', () => { + beforeEach(async () => { + setupTarget(harness, { + baseHref: '/test/', + }); + + // Application code is not needed for these tests + await harness.writeFile('src/main.ts', 'console.log("foo");'); + }); + + it('uses the baseHref defined in the "buildTarget" options as the serve path', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, '/test/main.js'); + + expect(result?.success).toBeTrue(); + const baseUrl = new URL(`${result?.baseUrl}/`); + expect(baseUrl.pathname).toBe('/test/'); + expect(await response?.text()).toContain('console.log'); + }); + + it('serves the application from baseHref location without trailing slash', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, '/test'); + + expect(result?.success).toBeTrue(); + expect(await response?.text()).toContain(' + + + + + %SCRIPTS% + + + + + + diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma-debug.html b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma-debug.html new file mode 100644 index 000000000000..dc58d8e6ee60 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma-debug.html @@ -0,0 +1,46 @@ + + + + + %X_UA_COMPATIBLE% + Karma DEBUG RUNNER + + + + + + + + + + + + + + + + %SCRIPTS% + + + + + + diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts new file mode 100644 index 000000000000..07aac6f8b9d2 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/karma/karma.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/* eslint-disable */ +// TODO: cleanup this file, it's copied as is from Angular CLI. +import type { IncomingMessage, ServerResponse } from 'node:http'; +import * as path from 'node:path'; +import type { Compiler } from 'webpack'; +import webpackDevMiddleware from 'webpack-dev-middleware'; + +import { statsErrorsToString } from '../../utils/stats'; +import { logging } from '@angular-devkit/core'; +import { BuildOptions } from '../../../../utils/build-options'; +import { normalizeSourceMaps } from '../../../../utils/index'; + +let webpackMiddleware: webpackDevMiddleware.API; + +const init: any = (config: any, emitter: any) => { + if (!config.buildWebpack) { + throw new Error( + `The '@angular-devkit/build-angular/plugins/karma' karma plugin is meant to` + + ` be used from within Angular CLI and will not work correctly outside of it.`, + ); + } + const options = config.buildWebpack.options as BuildOptions; + const logger: logging.Logger = config.buildWebpack.logger; + + // Add a reporter that fixes sourcemap urls. + if (normalizeSourceMaps(options.sourceMap).scripts) { + config.reporters.unshift('@angular-devkit/build-angular--sourcemap-reporter'); + + // Code taken from https://github.com/tschaub/karma-source-map-support. + // We can't use it directly because we need to add it conditionally in this file, and karma + // frameworks cannot be added dynamically. + const smsPath = path.dirname(require.resolve('source-map-support')); + const ksmsPath = path.dirname(require.resolve('karma-source-map-support')); + + config.files.unshift( + { + pattern: path.join(smsPath, 'browser-source-map-support.js'), + included: true, + served: true, + watched: false, + }, + { pattern: path.join(ksmsPath, 'client.js'), included: true, served: true, watched: false }, + ); + } + + // When using code-coverage, auto-add karma-coverage. + if ( + options.codeCoverage && + !config.reporters.some((r: string) => r === 'coverage' || r === 'coverage-istanbul') + ) { + config.reporters.push('coverage'); + } + + const compiler = config.buildWebpack.compiler as Compiler; + const webpackMiddlewareConfig = { + // Hide webpack output because its noisy. + stats: false, + }; + + config.webpackMiddleware = { ...webpackMiddlewareConfig, ...config.webpackMiddleware }; + + // Our custom context and debug files list the webpack bundles directly instead of using + // the karma files array. + config.customContextFile = `${__dirname}/karma-context.html`; + config.customDebugFile = `${__dirname}/karma-debug.html`; + + // Add the webpack server fallback. + config.middleware = config.middleware || []; + config.middleware.push('@angular-devkit/build-angular--fallback'); + + webpackMiddleware = webpackDevMiddleware(compiler, webpackMiddlewareConfig); + emitter.on('exit', (done: any) => { + webpackMiddleware.close(() => compiler.close(() => done())); + }); + + let lastCompilationHash: string | undefined; + + return new Promise((resolve) => { + compiler.hooks.done.tap('karma', (stats) => { + if (stats.hasErrors()) { + lastCompilationHash = undefined; + + // Only generate needed JSON stats and when needed. + const statsJson = stats.toJson({ + all: false, + children: true, + errors: true, + warnings: true, + }); + + logger.error(statsErrorsToString(statsJson, { colors: true })); + + if (config.singleRun) { + // Notify potential listeners of the compile error. + emitter.emit('load_error'); + } + + // Finish Karma run early in case of compilation error. + emitter.emit('run_complete', [], { exitCode: 1 }); + } else if (stats.hash != lastCompilationHash) { + // Refresh karma only when there are no webpack errors, and if the compilation changed. + lastCompilationHash = stats.hash; + emitter.refreshFiles(); + } + + // This is needed to block Karma from launching browsers before Webpack writes the assets in memory. + // See the below: + // https://github.com/karma-runner/karma-chrome-launcher/issues/154#issuecomment-986661937 + // https://github.com/angular/angular-cli/issues/22495 + resolve(); + }); + }); +}; + +init.$inject = ['config', 'emitter']; + +// Copied from "karma-jasmine-diff-reporter" source code: +// In case, when multiple reporters are used in conjunction +// with initSourcemapReporter, they both will show repetitive log +// messages when displaying everything that supposed to write to terminal. +// So just suppress any logs from initSourcemapReporter by doing nothing on +// browser log, because it is an utility reporter, +// unless it's alone in the "reporters" option and base reporter is used. +function muteDuplicateReporterLogging(context: any, config: any) { + context.writeCommonMsg = () => {}; + const reporterName = '@angular/cli'; + const hasTrailingReporters = config.reporters.slice(-1).pop() !== reporterName; + + if (hasTrailingReporters) { + context.writeCommonMsg = () => {}; + } +} + +// Strip the server address and webpack scheme (webpack://) from error log. +const sourceMapReporter: any = function (this: any, baseReporterDecorator: any, config: any) { + baseReporterDecorator(this); + muteDuplicateReporterLogging(this, config); + + const urlRegexp = /http:\/\/localhost:\d+\/(webpack:\/)?/gi; + + this.onSpecComplete = function (_browser: any, result: any) { + if (!result.success) { + result.log = result.log.map((l: string) => l.replace(urlRegexp, '')); + } + }; + + // avoid duplicate complete message + this.onRunComplete = () => {}; + + // avoid duplicate failure message + this.specFailure = () => {}; +}; + +sourceMapReporter.$inject = ['baseReporterDecorator', 'config']; + +/** + * List of files that are always served by the webpack server. + */ +const alwaysServe: ReadonlySet = new Set([ + '/runtime.js', + '/polyfills.js', + '/scripts.js', + '/styles.css', + '/vendor.js', +]); + +// When a request is not found in the karma server, try looking for it from the webpack server root. +function fallbackMiddleware() { + return function ( + request: IncomingMessage, + response: ServerResponse, + next: (err?: unknown) => void, + ) { + if (!webpackMiddleware) { + next(); + return; + } + + webpackMiddleware(request, response, () => { + if (request.url && alwaysServe.has(request.url)) { + response.statusCode = 200; + response.end(); + } else { + next(); + } + }); + }; +} + +module.exports = { + 'framework:@angular-devkit/build-angular': ['factory', init], + 'reporter:@angular-devkit/build-angular--sourcemap-reporter': ['type', sourceMapReporter], + 'middleware:@angular-devkit/build-angular--fallback': ['factory', fallbackMiddleware], +}; diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/named-chunks-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/named-chunks-plugin.ts new file mode 100644 index 000000000000..417d11484ef7 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/named-chunks-plugin.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { AsyncDependenciesBlock, Chunk, Compiler, Template, dependencies } from 'webpack'; + +// `ImportDependency` is not part of Webpack's depenencies typings. +const ImportDependency: typeof dependencies.ModuleDependency = require('webpack/lib/dependencies/import/ImportDependency'); + +const PLUGIN_NAME = 'named-chunks-plugin'; + +/** + * Webpack will not populate the chunk `name` property unless `webpackChunkName` magic comment is used. + * This however will also effect the filename which is not desired when using `deterministic` chunkIds. + * This plugin will populate the chunk `name` which is mainly used so that users can set bundle budgets on lazy chunks. + */ +export class NamedChunksPlugin { + apply(compiler: Compiler) { + compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { + compilation.hooks.chunkAsset.tap(PLUGIN_NAME, (chunk) => { + if (chunk.name) { + return; + } + + if ([...chunk.files.values()].every((f) => f.endsWith('.css'))) { + // If all chunk files are CSS files skip. + // This happens when using `import('./styles.css')` in a lazy loaded module. + return undefined; + } + + const name = this.generateName(chunk); + if (name) { + chunk.name = name; + } + }); + }); + } + + private generateName(chunk: Chunk): string | undefined { + for (const group of chunk.groupsIterable) { + const [block] = group.getBlocks(); + if (!(block instanceof AsyncDependenciesBlock)) { + continue; + } + + if (block.groupOptions.name) { + // Ignore groups which have been named already. + return undefined; + } + + for (const dependency of block.dependencies) { + if (dependency instanceof ImportDependency) { + return Template.toPath(dependency.request); + } + } + } + + return undefined; + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/occurrences-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/occurrences-plugin.ts new file mode 100644 index 000000000000..0357b267071a --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/occurrences-plugin.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Compiler } from 'webpack'; + +const PLUGIN_NAME = 'angular-occurrences-plugin'; + +export interface OccurrencesPluginOptions { + aot?: boolean; + scriptsOptimization?: boolean; +} + +export class OccurrencesPlugin { + constructor(private options: OccurrencesPluginOptions) {} + + apply(compiler: Compiler) { + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + compilation.hooks.processAssets.tapPromise( + { + name: PLUGIN_NAME, + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE, + }, + async (compilationAssets) => { + for (const assetName of Object.keys(compilationAssets)) { + if (!assetName.endsWith('.js')) { + continue; + } + + const scriptAsset = compilation.getAsset(assetName); + if (!scriptAsset || scriptAsset.source.size() <= 0) { + continue; + } + + const src = scriptAsset.source.source().toString('utf-8'); + + let ngComponentCount = 0; + + if (!this.options.aot) { + // Count the number of `Component({` strings (case sensitive), which happens in __decorate(). + ngComponentCount += this.countOccurrences(src, 'Component({'); + } + + if (this.options.scriptsOptimization) { + // for ascii_only true + ngComponentCount += this.countOccurrences(src, '.\\u0275cmp', false); + } else { + // For Ivy we just count ɵcmp.src + ngComponentCount += this.countOccurrences(src, '.ɵcmp', true); + } + + compilation.updateAsset( + assetName, + (s) => s, + (assetInfo) => ({ + ...assetInfo, + ngComponentCount, + }), + ); + } + }, + ); + }); + } + + private countOccurrences(source: string, match: string, wordBreak = false): number { + let count = 0; + + // We condition here so branch prediction happens out of the loop, not in it. + if (wordBreak) { + const re = /\w/; + for (let pos = source.lastIndexOf(match); pos >= 0; pos = source.lastIndexOf(match, pos)) { + if (!(re.test(source[pos - 1] || '') || re.test(source[pos + match.length] || ''))) { + count++; // 1 match, AH! AH! AH! 2 matches, AH! AH! AH! + } + + pos -= match.length; + if (pos < 0) { + break; + } + } + } else { + for (let pos = source.lastIndexOf(match); pos >= 0; pos = source.lastIndexOf(match, pos)) { + count++; // 1 match, AH! AH! AH! 2 matches, AH! AH! AH! + pos -= match.length; + if (pos < 0) { + break; + } + } + } + + return count; + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/postcss-cli-resources.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/postcss-cli-resources.ts new file mode 100644 index 000000000000..37ee30b5f9bf --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/postcss-cli-resources.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { interpolateName } from 'loader-utils'; +import * as path from 'node:path'; +import * as url from 'node:url'; +import { Declaration, Plugin } from 'postcss'; +import { assertIsError } from '../../../utils/error'; + +function wrapUrl(url: string): string { + let wrappedUrl; + const hasSingleQuotes = url.indexOf("'") >= 0; + + if (hasSingleQuotes) { + wrappedUrl = `"${url}"`; + } else { + wrappedUrl = `'${url}'`; + } + + return `url(${wrappedUrl})`; +} + +export interface PostcssCliResourcesOptions { + baseHref?: string; + deployUrl?: string; + resourcesOutputPath?: string; + rebaseRootRelative?: boolean; + + /** CSS is extracted to a `.css` or is embedded in a `.js` file. */ + extracted?: boolean; + filename: (resourcePath: string) => string; + loader: import('webpack').LoaderContext; + emitFile: boolean; +} + +async function resolve( + file: string, + base: string, + resolver: (file: string, base: string) => Promise, +): Promise { + try { + return await resolver('./' + file, base); + } catch { + return resolver(file, base); + } +} + +export const postcss = true; + +export default function (options?: PostcssCliResourcesOptions): Plugin { + if (!options) { + throw new Error('No options were specified to "postcss-cli-resources".'); + } + + const { + deployUrl = '', + resourcesOutputPath = '', + filename, + loader, + emitFile, + extracted, + } = options; + + const process = async (inputUrl: string, context: string, resourceCache: Map) => { + // If root-relative, absolute or protocol relative url, leave as is + if (/^((?:\w+:)?\/\/|data:|chrome:|#)/.test(inputUrl)) { + return inputUrl; + } + + if (/^\//.test(inputUrl)) { + return inputUrl; + } + + // If starts with a caret, remove and return remainder + // this supports bypassing asset processing + if (inputUrl.startsWith('^')) { + return inputUrl.slice(1); + } + + const cacheKey = path.resolve(context, inputUrl); + const cachedUrl = resourceCache.get(cacheKey); + if (cachedUrl) { + return cachedUrl; + } + + if (inputUrl.startsWith('~')) { + inputUrl = inputUrl.slice(1); + } + + const { pathname, hash, search } = url.parse(inputUrl.replace(/\\/g, '/')); + const resolver = (file: string, base: string) => + new Promise((resolve, reject) => { + loader.resolve(base, decodeURI(file), (err, result) => { + if (err) { + reject(err); + + return; + } + resolve(result as string); + }); + }); + + const result = await resolve(pathname as string, context, resolver); + + return new Promise((resolve, reject) => { + loader.fs.readFile(result, (err, content) => { + if (err) { + reject(err); + + return; + } + + let outputPath = interpolateName( + // TODO: Revisit. Previously due to lack of type safety, this object + // was fine, but in practice it doesn't match the type of the loader context. + { resourcePath: result } as Parameters[0], + filename(result), + { + content, + context: loader.context || loader.rootContext, + }, + ).replace(/\\|\//g, '-'); + + if (resourcesOutputPath) { + outputPath = path.posix.join(resourcesOutputPath, outputPath); + } + + loader.addDependency(result); + if (emitFile) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + loader.emitFile(outputPath, content!, undefined, { sourceFilename: result }); + } + + let outputUrl = outputPath.replace(/\\/g, '/'); + if (hash || search) { + outputUrl = url.format({ pathname: outputUrl, hash, search }); + } + + if (deployUrl && !extracted) { + outputUrl = url.resolve(deployUrl, outputUrl); + } + + resourceCache.set(cacheKey, outputUrl); + resolve(outputUrl); + }); + }); + }; + + const resourceCache = new Map(); + const processed = Symbol('postcss-cli-resources'); + + return { + postcssPlugin: 'postcss-cli-resources', + async Declaration(decl) { + if (!decl.value.includes('url') || processed in decl) { + return; + } + + const value = decl.value; + const urlRegex = /url(?:\(\s*(['"]?))(.*?)(?:\1\s*\))/g; + const segments: string[] = []; + + let match; + let lastIndex = 0; + let modified = false; + + // We want to load it relative to the file that imports + const inputFile = decl.source && decl.source.input.file; + const context = (inputFile && path.dirname(inputFile)) || loader.context; + + while ((match = urlRegex.exec(value))) { + const originalUrl = match[2]; + let processedUrl; + try { + processedUrl = await process(originalUrl, context, resourceCache); + } catch (err) { + assertIsError(err); + loader.emitError(decl.error(err.message, { word: originalUrl })); + continue; + } + + if (lastIndex < match.index) { + segments.push(value.slice(lastIndex, match.index)); + } + + if (!processedUrl || originalUrl === processedUrl) { + segments.push(match[0]); + } else { + segments.push(wrapUrl(processedUrl)); + modified = true; + } + + lastIndex = match.index + match[0].length; + } + + if (lastIndex < value.length) { + segments.push(value.slice(lastIndex)); + } + + if (modified) { + decl.value = segments.join(''); + } + + (decl as Declaration & { [processed]: boolean })[processed] = true; + }, + }; +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/progress-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/progress-plugin.ts new file mode 100644 index 000000000000..823220eb796e --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/progress-plugin.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { ProgressPlugin as WebpackProgressPlugin } from 'webpack'; +import { Spinner } from '../../../utils/spinner'; + +export class ProgressPlugin extends WebpackProgressPlugin { + constructor(platform: 'server' | 'browser') { + const platformCapitalFirst = platform.replace(/^\w/, (s) => s.toUpperCase()); + const spinner = new Spinner(); + spinner.start(`Generating ${platform} application bundles (phase: setup)...`); + + super({ + handler: (percentage: number, message: string) => { + const phase = message ? ` (phase: ${message})` : ''; + spinner.text = `Generating ${platform} application bundles${phase}...`; + + switch (percentage) { + case 1: + if (spinner.isSpinning) { + spinner.succeed(`${platformCapitalFirst} application bundle generation complete.`); + } + break; + case 0: + if (!spinner.isSpinning) { + spinner.start(); + } + break; + } + }, + }); + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/remove-hash-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/remove-hash-plugin.ts new file mode 100644 index 000000000000..f4b8c3311689 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/remove-hash-plugin.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Compiler } from 'webpack'; +import { HashFormat } from '../utils/helpers'; + +export interface RemoveHashPluginOptions { + chunkNames: string[]; + hashFormat: HashFormat; +} + +export class RemoveHashPlugin { + constructor(private options: RemoveHashPluginOptions) {} + + apply(compiler: Compiler): void { + compiler.hooks.compilation.tap('remove-hash-plugin', (compilation) => { + const assetPath = (path: string, data: { chunk?: { name?: string | null } }) => { + const chunkName = data.chunk?.name; + const { chunkNames, hashFormat } = this.options; + + if (chunkName && chunkNames?.includes(chunkName)) { + // Replace hash formats with empty strings. + return path.replace(hashFormat.chunk, '').replace(hashFormat.extract, ''); + } + + return path; + }; + + compilation.hooks.assetPath.tap('remove-hash-plugin', assetPath); + }); + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/scripts-webpack-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/scripts-webpack-plugin.ts new file mode 100644 index 000000000000..10d7d2d245bb --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/scripts-webpack-plugin.ts @@ -0,0 +1,211 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { interpolateName } from 'loader-utils'; +import * as path from 'node:path'; +import { Chunk, Compilation, Compiler, sources as webpackSources } from 'webpack'; +import { assertIsError } from '../../../utils/error'; +import { addError } from '../../../utils/webpack-diagnostics'; + +const Entrypoint = require('webpack/lib/Entrypoint'); + +/** + * The name of the plugin provided to Webpack when tapping Webpack compiler hooks. + */ +const PLUGIN_NAME = 'scripts-webpack-plugin'; + +export interface ScriptsWebpackPluginOptions { + name: string; + sourceMap?: boolean; + scripts: string[]; + filename: string; + basePath: string; +} + +interface ScriptOutput { + filename: string; + source: webpackSources.CachedSource; +} + +function addDependencies(compilation: Compilation, scripts: string[]): void { + for (const script of scripts) { + compilation.fileDependencies.add(script); + } +} + +export class ScriptsWebpackPlugin { + private _lastBuildTime?: number; + private _cachedOutput?: ScriptOutput; + + constructor(private options: ScriptsWebpackPluginOptions) {} + + async shouldSkip(compilation: Compilation, scripts: string[]): Promise { + if (this._lastBuildTime == undefined) { + this._lastBuildTime = Date.now(); + + return false; + } + + for (const script of scripts) { + const scriptTime = await new Promise((resolve, reject) => { + compilation.fileSystemInfo.getFileTimestamp(script, (error, entry) => { + if (error) { + reject(error); + + return; + } + + resolve(entry && typeof entry !== 'string' ? entry.safeTime : undefined); + }); + }); + + if (!scriptTime || scriptTime > this._lastBuildTime) { + this._lastBuildTime = Date.now(); + + return false; + } + } + + return true; + } + + private _insertOutput( + compilation: Compilation, + { filename, source }: ScriptOutput, + cached = false, + ) { + const chunk = new Chunk(this.options.name); + chunk.rendered = !cached; + chunk.id = this.options.name; + chunk.ids = [chunk.id]; + chunk.files.add(filename); + + const entrypoint = new Entrypoint(this.options.name); + entrypoint.pushChunk(chunk); + chunk.addGroup(entrypoint); + compilation.entrypoints.set(this.options.name, entrypoint); + compilation.chunks.add(chunk); + + compilation.assets[filename] = source; + compilation.hooks.chunkAsset.call(chunk, filename); + } + + apply(compiler: Compiler): void { + if (this.options.scripts.length === 0) { + return; + } + + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + // Use the resolver from the compilation instead of compiler. + // Using the latter will causes a lot of `DescriptionFileUtils.loadDescriptionFile` calls. + // See: https://github.com/angular/angular-cli/issues/24634#issuecomment-1425782668 + const resolver = compilation.resolverFactory.get('normal', { + preferRelative: true, + useSyncFileSystemCalls: true, + // Caching must be disabled because it causes the resolver to become async after a rebuild. + cache: false, + }); + + const scripts: string[] = []; + + for (const script of this.options.scripts) { + try { + const resolvedPath = resolver.resolveSync({}, this.options.basePath, script); + if (resolvedPath) { + scripts.push(resolvedPath); + } else { + addError(compilation, `Cannot resolve '${script}'.`); + } + } catch (error) { + assertIsError(error); + addError(compilation, error.message); + } + } + + compilation.hooks.additionalAssets.tapPromise(PLUGIN_NAME, async () => { + if (await this.shouldSkip(compilation, scripts)) { + if (this._cachedOutput) { + this._insertOutput(compilation, this._cachedOutput, true); + } + + addDependencies(compilation, scripts); + + return; + } + + const sourceGetters = scripts.map((fullPath) => { + return new Promise((resolve, reject) => { + compilation.inputFileSystem.readFile( + fullPath, + (err?: Error | null, data?: string | Buffer) => { + if (err) { + reject(err); + + return; + } + + const content = data?.toString() ?? ''; + + let source; + if (this.options.sourceMap) { + // TODO: Look for source map file (for '.min' scripts, etc.) + + let adjustedPath = fullPath; + if (this.options.basePath) { + adjustedPath = path.relative(this.options.basePath, fullPath); + } + source = new webpackSources.OriginalSource(content, adjustedPath); + } else { + source = new webpackSources.RawSource(content); + } + + resolve(source); + }, + ); + }); + }); + + const sources = await Promise.all(sourceGetters); + const concatSource = new webpackSources.ConcatSource(); + sources.forEach((source) => { + concatSource.add(source); + concatSource.add('\n;'); + }); + + const combinedSource = new webpackSources.CachedSource(concatSource); + + const output = { filename: this.options.filename, source: combinedSource }; + this._insertOutput(compilation, output); + this._cachedOutput = output; + addDependencies(compilation, scripts); + }); + compilation.hooks.processAssets.tapPromise( + { + name: PLUGIN_NAME, + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_DEV_TOOLING, + }, + async () => { + const assetName = this.options.filename; + const asset = compilation.getAsset(assetName); + if (asset) { + const interpolatedFilename = interpolateName( + // TODO: Revisit. Previously due to lack of type safety, this object + // was fine, but in practice it doesn't match the type of the loader context. + { resourcePath: 'scripts.js' } as Parameters[0], + assetName, + { content: asset.source.source() }, + ); + if (assetName !== interpolatedFilename) { + compilation.renameAsset(assetName, interpolatedFilename); + } + } + }, + ); + }); + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/service-worker-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/service-worker-plugin.ts new file mode 100644 index 000000000000..8b49d4d817f6 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/service-worker-plugin.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { augmentAppWithServiceWorker } from '@angular/build/private'; +import type { Compiler } from 'webpack'; + +export interface ServiceWorkerPluginOptions { + projectRoot: string; + root: string; + baseHref?: string; + ngswConfigPath?: string; +} + +export class ServiceWorkerPlugin { + constructor(private readonly options: ServiceWorkerPluginOptions) {} + + apply(compiler: Compiler) { + compiler.hooks.done.tapPromise('angular-service-worker', async (stats) => { + if (stats.hasErrors()) { + // Don't generate a service worker if the compilation has errors. + // When there are errors some files will not be emitted which would cause other errors down the line such as readdir failures. + return; + } + + const { projectRoot, root, baseHref = '', ngswConfigPath } = this.options; + const { compilation } = stats; + // We use the output path from the compilation instead of build options since during + // localization the output path is modified to a temp directory. + // See: https://github.com/angular/angular-cli/blob/7e64b1537d54fadb650559214fbb12707324cd75/packages/angular_devkit/build_angular/src/utils/i18n-options.ts#L251-L252 + const outputPath = compilation.outputOptions.path; + + if (!outputPath) { + throw new Error('Compilation output path cannot be empty.'); + } + + try { + await augmentAppWithServiceWorker( + projectRoot, + root, + outputPath, + baseHref, + ngswConfigPath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (compiler.inputFileSystem as any).promises, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (compiler.outputFileSystem as any).promises, + ); + } catch (error) { + compilation.errors.push( + new compilation.compiler.webpack.WebpackError( + `Failed to generate service worker - ${error instanceof Error ? error.message : error}`, + ), + ); + } + }); + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/styles-webpack-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/styles-webpack-plugin.ts new file mode 100644 index 000000000000..38555f56a093 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/styles-webpack-plugin.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import type { Compilation, Compiler } from 'webpack'; +import { assertIsError } from '../../../utils/error'; +import { addError } from '../../../utils/webpack-diagnostics'; + +export interface StylesWebpackPluginOptions { + preserveSymlinks?: boolean; + root: string; + entryPoints: Record; +} + +/** + * The name of the plugin provided to Webpack when tapping Webpack compiler hooks. + */ +const PLUGIN_NAME = 'styles-webpack-plugin'; + +export class StylesWebpackPlugin { + private compilation: Compilation | undefined; + + constructor(private readonly options: StylesWebpackPluginOptions) {} + + apply(compiler: Compiler): void { + const { entryPoints, preserveSymlinks, root } = this.options; + const resolver = compiler.resolverFactory.get('global-styles', { + conditionNames: ['sass', 'less', 'style'], + mainFields: ['sass', 'less', 'style', 'main', '...'], + extensions: ['.scss', '.sass', '.less', '.css'], + restrictions: [/\.((le|sa|sc|c)ss)$/i], + preferRelative: true, + useSyncFileSystemCalls: true, + symlinks: !preserveSymlinks, + fileSystem: compiler.inputFileSystem ?? undefined, + }); + + const webpackOptions = compiler.options; + compiler.hooks.environment.tap(PLUGIN_NAME, () => { + const entry = + typeof webpackOptions.entry === 'function' ? webpackOptions.entry() : webpackOptions.entry; + + webpackOptions.entry = async () => { + const entrypoints = await entry; + + for (const [bundleName, paths] of Object.entries(entryPoints)) { + entrypoints[bundleName] ??= {}; + const entryImport = (entrypoints[bundleName].import ??= []); + + for (const path of paths) { + try { + const resolvedPath = resolver.resolveSync({}, root, path); + if (resolvedPath) { + entryImport.push(`${resolvedPath}?ngGlobalStyle`); + } else { + assert(this.compilation, 'Compilation cannot be undefined.'); + addError(this.compilation, `Cannot resolve '${path}'.`); + } + } catch (error) { + assert(this.compilation, 'Compilation cannot be undefined.'); + assertIsError(error); + addError(this.compilation, error.message); + } + } + } + + return entrypoints; + }; + }); + + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + this.compilation = compilation; + }); + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/suppress-entry-chunks-webpack-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/suppress-entry-chunks-webpack-plugin.ts new file mode 100644 index 000000000000..aae12361dd5a --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/suppress-entry-chunks-webpack-plugin.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * Remove .js files from entry points consisting entirely of stylesheets. + * To be used together with mini-css-extract-plugin. + */ +export class SuppressExtractedTextChunksWebpackPlugin { + apply(compiler: import('webpack').Compiler): void { + compiler.hooks.compilation.tap('SuppressExtractedTextChunks', (compilation) => { + compilation.hooks.chunkAsset.tap('SuppressExtractedTextChunks', (chunk, filename) => { + // Remove only JavaScript assets + if (!filename.endsWith('.js')) { + return; + } + + // Only chunks with a css asset should have JavaScript assets removed + let hasCssFile = false; + for (const file of chunk.files) { + if (file.endsWith('.css')) { + hasCssFile = true; + break; + } + } + + if (!hasCssFile) { + return; + } + + // Only chunks with all CSS entry dependencies should have JavaScript assets removed + let cssOnly = false; + const entryModules = compilation.chunkGraph.getChunkEntryModulesIterable(chunk); + for (const module of entryModules) { + cssOnly = module.dependencies.every( + (dependency: {}) => dependency.constructor.name === 'CssDependency', + ); + + if (!cssOnly) { + break; + } + } + + if (cssOnly) { + chunk.files.delete(filename); + compilation.deleteAsset(filename); + } + }); + }); + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/transfer-size-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/transfer-size-plugin.ts new file mode 100644 index 000000000000..77122667452f --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/transfer-size-plugin.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { promisify } from 'node:util'; +import { brotliCompress } from 'node:zlib'; +import { Compiler } from 'webpack'; +import { addWarning } from '../../../utils/webpack-diagnostics'; + +const brotliCompressAsync = promisify(brotliCompress); + +const PLUGIN_NAME = 'angular-transfer-size-estimator'; + +export class TransferSizePlugin { + constructor() {} + + apply(compiler: Compiler) { + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + compilation.hooks.processAssets.tapPromise( + { + name: PLUGIN_NAME, + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE, + }, + async (compilationAssets) => { + const actions = []; + for (const assetName of Object.keys(compilationAssets)) { + if (!assetName.endsWith('.js') && !assetName.endsWith('.css')) { + continue; + } + + const scriptAsset = compilation.getAsset(assetName); + if (!scriptAsset || scriptAsset.source.size() <= 0) { + continue; + } + + actions.push( + brotliCompressAsync(scriptAsset.source.source()) + .then((result) => { + compilation.updateAsset( + assetName, + (s) => s, + (assetInfo) => ({ + ...assetInfo, + estimatedTransferSize: result.length, + }), + ); + }) + .catch((error) => { + addWarning( + compilation, + `Unable to calculate estimated transfer size for '${assetName}'. Reason: ${error.message}`, + ); + }), + ); + } + + await Promise.all(actions); + }, + ); + }); + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/typescript.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/typescript.ts new file mode 100644 index 000000000000..129c869036d2 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/typescript.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { CompilerOptions } from '@angular/compiler-cli'; +import { AngularWebpackPlugin } from '@ngtools/webpack'; +import { ScriptTarget } from 'typescript'; +import { WebpackConfigOptions } from '../../../utils/build-options'; + +export function createIvyPlugin( + wco: WebpackConfigOptions, + aot: boolean, + tsconfig: string, +): AngularWebpackPlugin { + const { buildOptions, tsConfig } = wco; + const optimize = buildOptions.optimization.scripts; + + const compilerOptions: CompilerOptions = { + sourceMap: buildOptions.sourceMap.scripts, + declaration: false, + declarationMap: false, + // Disable removing of comments as TS is quite aggressive with these and can + // remove important annotations, such as /* @__PURE__ */. + removeComments: false, + }; + + if (tsConfig.options.target === undefined || tsConfig.options.target < ScriptTarget.ES2022) { + compilerOptions.target = ScriptTarget.ES2022; + // If 'useDefineForClassFields' is already defined in the users project leave the value as is. + // Otherwise fallback to false due to https://github.com/microsoft/TypeScript/issues/45995 + // which breaks the deprecated `@Effects` NGRX decorator and potentially other existing code as well. + compilerOptions.useDefineForClassFields ??= false; + + wco.logger.warn( + 'TypeScript compiler options "target" and "useDefineForClassFields" are set to "ES2022" and ' + + '"false" respectively by the Angular CLI. To control ECMA version and features use the Browserslist configuration. ' + + 'For more information, see https://angular.dev/tools/cli/build#configuring-browser-compatibility\n' + + `NOTE: You can set the "target" to "ES2022" in the project's tsconfig to remove this warning.`, + ); + } + + if (buildOptions.preserveSymlinks !== undefined) { + compilerOptions.preserveSymlinks = buildOptions.preserveSymlinks; + } + + const fileReplacements: Record = {}; + if (buildOptions.fileReplacements) { + for (const replacement of buildOptions.fileReplacements) { + fileReplacements[replacement.replace] = replacement.with; + } + } + + return new AngularWebpackPlugin({ + tsconfig, + compilerOptions, + fileReplacements, + jitMode: !aot, + emitNgModuleScope: !optimize, + inlineStyleFileExtension: buildOptions.inlineStyleLanguage ?? 'css', + }); +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/watch-files-logs-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/watch-files-logs-plugin.ts new file mode 100644 index 000000000000..d56e0e53db32 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/watch-files-logs-plugin.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Compiler } from 'webpack'; + +const PLUGIN_NAME = 'angular.watch-files-logs-plugin'; + +export class WatchFilesLogsPlugin { + apply(compiler: Compiler) { + compiler.hooks.watchRun.tap(PLUGIN_NAME, ({ modifiedFiles, removedFiles }) => { + compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { + const logger = compilation.getLogger(PLUGIN_NAME); + if (modifiedFiles?.size) { + logger.log(`Modified files:\n${[...modifiedFiles].join('\n')}\n`); + } + + if (removedFiles?.size) { + logger.log(`Removed files:\n${[...removedFiles].join('\n')}\n`); + } + }); + }); + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/utils/async-chunks.ts b/packages/angular_devkit/build_angular/src/tools/webpack/utils/async-chunks.ts new file mode 100644 index 000000000000..00eb0740fb11 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/utils/async-chunks.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { StatsChunk, StatsCompilation } from 'webpack'; +import { NormalizedEntryPoint } from './helpers'; + +/** + * Webpack stats may incorrectly mark extra entry points `initial` chunks, when + * they are actually loaded asynchronously and thus not in the main bundle. This + * function finds extra entry points in Webpack stats and corrects this value + * whereever necessary. Does not modify {@param webpackStats}. + */ +export function markAsyncChunksNonInitial( + webpackStats: StatsCompilation, + extraEntryPoints: NormalizedEntryPoint[], +): StatsChunk[] { + const { chunks = [], entrypoints: entryPoints = {} } = webpackStats; + + // Find all Webpack chunk IDs not injected into the main bundle. We don't have + // to worry about transitive dependencies because extra entry points cannot be + // depended upon in Webpack, thus any extra entry point with `inject: false`, + // **cannot** be loaded in main bundle. + const asyncChunkIds = extraEntryPoints + .filter((entryPoint) => !entryPoint.inject && entryPoints[entryPoint.bundleName]) + .flatMap((entryPoint) => + entryPoints[entryPoint.bundleName].chunks?.filter((n) => n !== 'runtime'), + ); + + // Find chunks for each ID. + const asyncChunks = asyncChunkIds.map((chunkId) => { + const chunk = chunks.find((chunk) => chunk.id === chunkId); + if (!chunk) { + throw new Error(`Failed to find chunk (${chunkId}) in set:\n${JSON.stringify(chunks)}`); + } + + return chunk; + }); + + // A chunk is considered `initial` only if Webpack already belives it to be initial + // and the application developer did not mark it async via an extra entry point. + return chunks.map((chunk) => { + return asyncChunks.find((asyncChunk) => asyncChunk === chunk) + ? { + ...chunk, + initial: false, + } + : chunk; + }); +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/utils/helpers.ts b/packages/angular_devkit/build_angular/src/tools/webpack/utils/helpers.ts new file mode 100644 index 000000000000..e359597ab45e --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/utils/helpers.ts @@ -0,0 +1,325 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { ObjectPattern } from 'copy-webpack-plugin'; +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; +import * as path from 'node:path'; +import { globSync } from 'tinyglobby'; +import type { Configuration, WebpackOptionsNormalized } from 'webpack'; +import { + AssetPatternClass, + OutputHashing, + ScriptElement, + StyleElement, +} from '../../../builders/browser/schema'; +import { WebpackConfigOptions } from '../../../utils/build-options'; +import { VERSION } from '../../../utils/package-version'; + +export interface HashFormat { + chunk: string; + extract: string; + file: string; + script: string; +} + +export type WebpackStatsOptions = Exclude; + +export function getOutputHashFormat(outputHashing = OutputHashing.None, length = 20): HashFormat { + const hashTemplate = `.[contenthash:${length}]`; + + switch (outputHashing) { + case 'media': + return { + chunk: '', + extract: '', + file: hashTemplate, + script: '', + }; + case 'bundles': + return { + chunk: hashTemplate, + extract: hashTemplate, + file: '', + script: hashTemplate, + }; + case 'all': + return { + chunk: hashTemplate, + extract: hashTemplate, + file: hashTemplate, + script: hashTemplate, + }; + case 'none': + default: + return { + chunk: '', + extract: '', + file: '', + script: '', + }; + } +} + +export type NormalizedEntryPoint = Required>; + +export function normalizeExtraEntryPoints( + extraEntryPoints: (ScriptElement | StyleElement)[], + defaultBundleName: string, +): NormalizedEntryPoint[] { + return extraEntryPoints.map((entry) => { + if (typeof entry === 'string') { + return { input: entry, inject: true, bundleName: defaultBundleName }; + } + + const { inject = true, ...newEntry } = entry; + let bundleName; + if (entry.bundleName) { + bundleName = entry.bundleName; + } else if (!inject) { + // Lazy entry points use the file name as bundle name. + bundleName = path.parse(entry.input).name; + } else { + bundleName = defaultBundleName; + } + + return { ...newEntry, inject, bundleName }; + }); +} + +export function assetNameTemplateFactory(hashFormat: HashFormat): (resourcePath: string) => string { + const visitedFiles = new Map(); + + return (resourcePath: string) => { + if (hashFormat.file) { + // File names are hashed therefore we don't need to handle files with the same file name. + return `[name]${hashFormat.file}.[ext]`; + } + + const filename = path.basename(resourcePath); + // Check if the file with the same name has already been processed. + const visited = visitedFiles.get(filename); + if (!visited) { + // Not visited. + visitedFiles.set(filename, resourcePath); + + return filename; + } else if (visited === resourcePath) { + // Same file. + return filename; + } + + // File has the same name but it's in a different location. + return '[path][name].[ext]'; + }; +} + +export function getInstrumentationExcludedPaths( + root: string, + excludedPaths: string[], +): Set { + const excluded = new Set(); + + for (const excludeGlob of excludedPaths) { + const excludePath = excludeGlob[0] === '/' ? excludeGlob.slice(1) : excludeGlob; + globSync(excludePath, { cwd: root }).forEach((p) => excluded.add(path.join(root, p))); + } + + return excluded; +} + +export function normalizeGlobalStyles(styleEntrypoints: StyleElement[]): { + entryPoints: Record; + noInjectNames: string[]; +} { + const entryPoints: Record = {}; + const noInjectNames: string[] = []; + + if (styleEntrypoints.length === 0) { + return { entryPoints, noInjectNames }; + } + + for (const style of normalizeExtraEntryPoints(styleEntrypoints, 'styles')) { + // Add style entry points. + entryPoints[style.bundleName] ??= []; + entryPoints[style.bundleName].push(style.input); + + // Add non injected styles to the list. + if (!style.inject) { + noInjectNames.push(style.bundleName); + } + } + + return { entryPoints, noInjectNames }; +} + +export function getCacheSettings( + wco: WebpackConfigOptions, + angularVersion: string, +): WebpackOptionsNormalized['cache'] { + const { enabled, path: cacheDirectory } = wco.buildOptions.cache; + if (enabled) { + return { + type: 'filesystem', + profile: wco.buildOptions.verbose, + cacheDirectory: path.join(cacheDirectory, 'angular-webpack'), + maxMemoryGenerations: 1, + // We use the versions and build options as the cache name. The Webpack configurations are too + // dynamic and shared among different build types: test, build and serve. + // None of which are "named". + name: createHash('sha1') + .update(angularVersion) + .update(VERSION) + .update(wco.projectRoot) + .update(JSON.stringify(wco.tsConfig)) + .update( + JSON.stringify({ + ...wco.buildOptions, + // Needed because outputPath changes on every build when using i18n extraction + // https://github.com/angular/angular-cli/blob/736a5f89deaca85f487b78aec9ff66d4118ceb6a/packages/angular_devkit/build_angular/src/utils/i18n-options.ts#L264-L265 + outputPath: undefined, + }), + ) + .digest('hex'), + }; + } + + if (wco.buildOptions.watch) { + return { + type: 'memory', + maxGenerations: 1, + }; + } + + return false; +} + +export function globalScriptsByBundleName( + scripts: ScriptElement[], +): { bundleName: string; inject: boolean; paths: string[] }[] { + return normalizeExtraEntryPoints(scripts, 'scripts').reduce( + (prev: { bundleName: string; paths: string[]; inject: boolean }[], curr) => { + const { bundleName, inject, input } = curr; + + const existingEntry = prev.find((el) => el.bundleName === bundleName); + if (existingEntry) { + if (existingEntry.inject && !inject) { + // All entries have to be lazy for the bundle to be lazy. + throw new Error(`The ${bundleName} bundle is mixing injected and non-injected scripts.`); + } + + existingEntry.paths.push(input); + } else { + prev.push({ + bundleName, + inject, + paths: [input], + }); + } + + return prev; + }, + [], + ); +} + +export function assetPatterns(root: string, assets: AssetPatternClass[]) { + return assets.map((asset: AssetPatternClass, index: number): ObjectPattern => { + // Resolve input paths relative to workspace root and add slash at the end. + // eslint-disable-next-line prefer-const + let { input, output = '', ignore = [], glob } = asset; + input = path.resolve(root, input).replace(/\\/g, '/'); + input = input.endsWith('/') ? input : input + '/'; + output = output.endsWith('/') ? output : output + '/'; + + if (output.startsWith('..')) { + throw new Error('An asset cannot be written to a location outside of the output path.'); + } + + return { + context: input, + // Now we remove starting slash to make Webpack place it from the output root. + to: output.replace(/^\//, ''), + from: glob, + noErrorOnMissing: true, + force: true, + globOptions: { + dot: true, + followSymbolicLinks: !!asset.followSymlinks, + ignore: [ + '.gitkeep', + '**/.DS_Store', + '**/Thumbs.db', + // Negate patterns needs to be absolute because copy-webpack-plugin uses absolute globs which + // causes negate patterns not to match. + // See: https://github.com/webpack-contrib/copy-webpack-plugin/issues/498#issuecomment-639327909 + ...ignore, + ].map((i) => path.posix.join(input, i)), + }, + priority: index, + }; + }); +} + +export function getStatsOptions(verbose = false): WebpackStatsOptions { + const webpackOutputOptions: WebpackStatsOptions = { + all: false, // Fallback value for stats options when an option is not defined. It has precedence over local webpack defaults. + colors: true, + hash: true, // required by custom stat output + timings: true, // required by custom stat output + chunks: true, // required by custom stat output + builtAt: true, // required by custom stat output + warnings: true, + errors: true, + assets: true, // required by custom stat output + cachedAssets: true, // required for bundle size calculators + + // Needed for markAsyncChunksNonInitial. + ids: true, + entrypoints: true, + }; + + const verboseWebpackOutputOptions: WebpackStatsOptions = { + // The verbose output will most likely be piped to a file, so colors just mess it up. + colors: false, + usedExports: true, + optimizationBailout: true, + reasons: true, + children: true, + assets: true, + version: true, + chunkModules: true, + errorDetails: true, + errorStack: true, + moduleTrace: true, + logging: 'verbose', + modulesSpace: Infinity, + }; + + return verbose + ? { ...webpackOutputOptions, ...verboseWebpackOutputOptions } + : webpackOutputOptions; +} + +/** + * Checks if a specified package is installed in the given workspace. + * + * @param root - The root directory of the workspace. + * @param name - The name of the package to check for. + * @returns `true` if the package is installed, `false` otherwise. + */ +export function isPackageInstalled(root: string, name: string): boolean { + try { + const resolve = createRequire(root + '/').resolve; + resolve(name); + + return true; + } catch { + return false; + } +} diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/utils/stats.ts b/packages/angular_devkit/build_angular/src/tools/webpack/utils/stats.ts new file mode 100644 index 000000000000..3bd046cbae15 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tools/webpack/utils/stats.ts @@ -0,0 +1,367 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + BudgetCalculatorResult, + BundleStats, + generateBuildStatsTable, +} from '@angular/build/private'; +import { WebpackLoggingCallback } from '@angular-devkit/build-webpack'; +import { logging } from '@angular-devkit/core'; +import assert from 'node:assert'; +import * as path from 'node:path'; +import { Configuration, StatsCompilation } from 'webpack'; +import { Schema as BrowserBuilderOptions } from '../../../builders/browser/schema'; +import { normalizeOptimization } from '../../../utils'; +import { colors as ansiColors } from '../../../utils/color'; +import { markAsyncChunksNonInitial } from './async-chunks'; +import { WebpackStatsOptions, getStatsOptions, normalizeExtraEntryPoints } from './helpers'; + +function getBuildDuration(webpackStats: StatsCompilation): number { + assert(webpackStats.builtAt, 'buildAt cannot be undefined'); + assert(webpackStats.time, 'time cannot be undefined'); + + return Date.now() - webpackStats.builtAt + webpackStats.time; +} + +function generateBundleStats(info: { + rawSize?: number; + estimatedTransferSize?: number; + files?: string[]; + names?: string[]; + initial?: boolean; + rendered?: boolean; +}): BundleStats { + const rawSize = typeof info.rawSize === 'number' ? info.rawSize : '-'; + const estimatedTransferSize = + typeof info.estimatedTransferSize === 'number' ? info.estimatedTransferSize : '-'; + const files = + info.files + ?.filter((f) => !f.endsWith('.map')) + .map((f) => path.basename(f)) + .join(', ') ?? ''; + const names = info.names?.length ? info.names.join(', ') : '-'; + const initial = !!info.initial; + + return { + initial, + stats: [files, names, rawSize, estimatedTransferSize], + }; +} + +// We use this cache because we can have multiple builders running in the same process, +// where each builder has different output path. + +// Ideally, we should create the logging callback as a factory, but that would need a refactoring. +const runsCache = new Set(); + +function statsToString( + json: StatsCompilation, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + statsConfig: any, + budgetFailures?: BudgetCalculatorResult[], +): string { + if (!json.chunks?.length) { + return ''; + } + + const colors = statsConfig.colors; + const rs = (x: string) => (colors ? ansiColors.reset(x) : x); + const w = (x: string) => (colors ? ansiColors.bold.white(x) : x); + + const changedChunksStats: BundleStats[] = []; + let hasEstimatedTransferSizes = false; + + const isFirstRun = !runsCache.has(json.outputPath || ''); + + for (const chunk of json.chunks) { + // During first build we want to display unchanged chunks + // but unchanged cached chunks are always marked as not rendered. + if (!isFirstRun && !chunk.rendered) { + continue; + } + + const assets = json.assets?.filter((asset) => chunk.files?.includes(asset.name)); + let rawSize = 0; + let estimatedTransferSize; + if (assets) { + for (const asset of assets) { + if (asset.name.endsWith('.map')) { + continue; + } + + rawSize += asset.size; + + if (typeof asset.info.estimatedTransferSize === 'number') { + if (estimatedTransferSize === undefined) { + estimatedTransferSize = 0; + hasEstimatedTransferSizes = true; + } + estimatedTransferSize += asset.info.estimatedTransferSize; + } + } + } + changedChunksStats.push(generateBundleStats({ ...chunk, rawSize, estimatedTransferSize })); + } + const unchangedChunkNumber = json.chunks.length - changedChunksStats.length; + + runsCache.add(json.outputPath || ''); + + const statsTable = generateBuildStatsTable( + changedChunksStats, + colors, + unchangedChunkNumber === 0, + hasEstimatedTransferSizes, + budgetFailures, + ); + + // In some cases we do things outside of webpack context + // Such us index generation, service worker augmentation etc... + // This will correct the time and include these. + const time = getBuildDuration(json); + + return rs( + `\n${statsTable}\n\n` + + (unchangedChunkNumber > 0 ? `${unchangedChunkNumber} unchanged chunks\n\n` : '') + + `Build at: ${w(new Date().toISOString())} - Hash: ${w(json.hash || '')} - Time: ${w('' + time)}ms`, + ); +} + +export function statsWarningsToString( + json: StatsCompilation, + statsConfig: WebpackStatsOptions, +): string { + const colors = statsConfig.colors; + const c = (x: string) => (colors ? ansiColors.reset.cyan(x) : x); + const y = (x: string) => (colors ? ansiColors.reset.yellow(x) : x); + const yb = (x: string) => (colors ? ansiColors.reset.yellowBright(x) : x); + + const warnings = json.warnings ? [...json.warnings] : []; + if (json.children) { + warnings.push(...json.children.map((c) => c.warnings ?? []).reduce((a, b) => [...a, ...b], [])); + } + + let output = ''; + for (const warning of warnings) { + if (typeof warning === 'string') { + output += yb(`Warning: ${warning}\n\n`); + } else { + let file = warning.file || warning.moduleName; + // Clean up warning paths + // Ex: ./src/app/styles.scss.webpack[javascript/auto]!=!./node_modules/css-loader/dist/cjs.js.... + // to ./src/app/styles.scss.webpack + if (file && !statsConfig.errorDetails) { + const webpackPathIndex = file.indexOf('.webpack['); + if (webpackPathIndex !== -1) { + file = file.substring(0, webpackPathIndex); + } + } + + if (file) { + output += c(file); + if (warning.loc) { + output += ':' + yb(warning.loc); + } + output += ' - '; + } + if (!/^warning/i.test(warning.message)) { + output += y('Warning: '); + } + output += `${warning.message}\n\n`; + } + } + + return output ? '\n' + output : output; +} + +export function statsErrorsToString( + json: StatsCompilation, + statsConfig: WebpackStatsOptions, +): string { + const colors = statsConfig.colors; + const c = (x: string) => (colors ? ansiColors.reset.cyan(x) : x); + const yb = (x: string) => (colors ? ansiColors.reset.yellowBright(x) : x); + const r = (x: string) => (colors ? ansiColors.reset.redBright(x) : x); + + const errors = json.errors ? [...json.errors] : []; + if (json.children) { + errors.push(...json.children.map((c) => c?.errors || []).reduce((a, b) => [...a, ...b], [])); + } + + let output = ''; + for (const error of errors) { + if (typeof error === 'string') { + output += r(`Error: ${error}\n\n`); + } else { + let file = error.file || error.moduleName; + // Clean up error paths + // Ex: ./src/app/styles.scss.webpack[javascript/auto]!=!./node_modules/css-loader/dist/cjs.js.... + // to ./src/app/styles.scss.webpack + if (file && !statsConfig.errorDetails) { + const webpackPathIndex = file.indexOf('.webpack['); + if (webpackPathIndex !== -1) { + file = file.substring(0, webpackPathIndex); + } + } + + if (file) { + output += c(file); + if (error.loc) { + output += ':' + yb(error.loc); + } + output += ' - '; + } + + // In most cases webpack will add stack traces to error messages. + // This below cleans up the error from stacks. + // See: https://github.com/webpack/webpack/issues/15980 + const index = error.message.search(/[\n\s]+at /); + let message = + statsConfig.errorStack || index === -1 ? error.message : error.message.substring(0, index); + + // Clean up error message paths when not verbose + // Ex: Execution of module code from module graph (./src/styles.scss.webpack[javascript/auto]!=!...) failed + // to Execution of module code from module graph (./src/styles.scss) failed + if (message && !statsConfig.errorDetails) { + message = message.replace(/([^(\s]+)\.webpack\[[^\]]+\]!=![^\s)]+/g, '$1'); + } + + if (!/^error/i.test(message)) { + output += r('Error: '); + } + output += `${message}\n\n`; + } + } + + return output ? '\n' + output : output; +} + +export function statsHasErrors(json: StatsCompilation): boolean { + return !!(json.errors?.length || json.children?.some((c) => c.errors?.length)); +} + +export function statsHasWarnings(json: StatsCompilation): boolean { + return !!(json.warnings?.length || json.children?.some((c) => c.warnings?.length)); +} + +export function createWebpackLoggingCallback( + options: BrowserBuilderOptions, + logger: logging.LoggerApi, +): WebpackLoggingCallback { + const { verbose = false, scripts = [], styles = [] } = options; + const extraEntryPoints = [ + ...normalizeExtraEntryPoints(styles, 'styles'), + ...normalizeExtraEntryPoints(scripts, 'scripts'), + ]; + + return (stats, config) => { + if (verbose && config.stats !== false) { + const statsOptions = config.stats === true ? undefined : config.stats; + logger.info(stats.toString(statsOptions)); + } + + const rawStats = stats.toJson(getStatsOptions(false)); + const webpackStats = { + ...rawStats, + chunks: markAsyncChunksNonInitial(rawStats, extraEntryPoints), + }; + + webpackStatsLogger(logger, webpackStats, config); + }; +} + +export interface BuildEventStats { + aot: boolean; + optimization: boolean; + allChunksCount: number; + lazyChunksCount: number; + initialChunksCount: number; + changedChunksCount?: number; + durationInMs: number; + cssSizeInBytes: number; + jsSizeInBytes: number; + ngComponentCount: number; +} + +export function generateBuildEventStats( + webpackStats: StatsCompilation, + browserBuilderOptions: BrowserBuilderOptions, +): BuildEventStats { + const { chunks = [], assets = [] } = webpackStats; + + let jsSizeInBytes = 0; + let cssSizeInBytes = 0; + let initialChunksCount = 0; + let ngComponentCount = 0; + let changedChunksCount = 0; + + const allChunksCount = chunks.length; + const isFirstRun = !runsCache.has(webpackStats.outputPath || ''); + + const chunkFiles = new Set(); + for (const chunk of chunks) { + if (!isFirstRun && chunk.rendered) { + changedChunksCount++; + } + + if (chunk.initial) { + initialChunksCount++; + } + + for (const file of chunk.files ?? []) { + chunkFiles.add(file); + } + } + + for (const asset of assets) { + if (asset.name.endsWith('.map') || !chunkFiles.has(asset.name)) { + continue; + } + + if (asset.name.endsWith('.js')) { + jsSizeInBytes += asset.size; + ngComponentCount += asset.info.ngComponentCount ?? 0; + } else if (asset.name.endsWith('.css')) { + cssSizeInBytes += asset.size; + } + } + + return { + optimization: !!normalizeOptimization(browserBuilderOptions.optimization).scripts, + aot: browserBuilderOptions.aot !== false, + allChunksCount, + lazyChunksCount: allChunksCount - initialChunksCount, + initialChunksCount, + changedChunksCount, + durationInMs: getBuildDuration(webpackStats), + cssSizeInBytes, + jsSizeInBytes, + ngComponentCount, + }; +} + +export function webpackStatsLogger( + logger: logging.LoggerApi, + json: StatsCompilation, + config: Configuration, + budgetFailures?: BudgetCalculatorResult[], +): void { + logger.info(statsToString(json, config.stats, budgetFailures)); + + if (typeof config.stats !== 'object') { + throw new Error('Invalid Webpack stats configuration.'); + } + + if (statsHasWarnings(json)) { + logger.warn(statsWarningsToString(json, config.stats)); + } + + if (statsHasErrors(json)) { + logger.error(statsErrorsToString(json, config.stats)); + } +} diff --git a/packages/angular_devkit/build_angular/src/transforms.ts b/packages/angular_devkit/build_angular/src/transforms.ts new file mode 100644 index 000000000000..6ae0678cd2da --- /dev/null +++ b/packages/angular_devkit/build_angular/src/transforms.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export type ExecutionTransformer = (input: T) => T | Promise; diff --git a/packages/angular_devkit/build_angular/src/tslint/index.ts b/packages/angular_devkit/build_angular/src/tslint/index.ts deleted file mode 100644 index 9fe76ac1fd88..000000000000 --- a/packages/angular_devkit/build_angular/src/tslint/index.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * @license - * 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 - */ - -import { - BuildEvent, - Builder, - BuilderConfiguration, - BuilderContext, -} from '@angular-devkit/architect'; -import { getSystemPath } from '@angular-devkit/core'; -import { readFileSync } from 'fs'; -import * as glob from 'glob'; -import { Minimatch } from 'minimatch'; -import * as path from 'path'; -import { Observable, from } from 'rxjs'; -import { concatMap } from 'rxjs/operators'; -import * as tslint from 'tslint'; // tslint:disable-line:no-implicit-dependencies -import * as ts from 'typescript'; // tslint:disable-line:no-implicit-dependencies -import { stripBom } from '../angular-cli-files/utilities/strip-bom'; - -export interface TslintBuilderOptions { - tslintConfig?: string; - tsConfig?: string | string[]; - fix: boolean; - typeCheck: boolean; - force: boolean; - silent: boolean; - format: string; - exclude: string[]; - files: string[]; -} - -export default class TslintBuilder implements Builder { - - constructor(public context: BuilderContext) { } - - private async loadTslint() { - let tslint; - try { - tslint = await import('tslint'); // tslint:disable-line:no-implicit-dependencies - } catch { - throw new Error('Unable to find TSLint. Ensure TSLint is installed.'); - } - - const version = tslint.Linter.VERSION && tslint.Linter.VERSION.split('.'); - if (!version || version.length < 2 || Number(version[0]) < 5 || Number(version[1]) < 5) { - throw new Error('TSLint must be version 5.5 or higher.'); - } - - return tslint; - } - - run(builderConfig: BuilderConfiguration): Observable { - - const root = this.context.workspace.root; - const systemRoot = getSystemPath(root); - const options = builderConfig.options; - - if (!options.tsConfig && options.typeCheck) { - throw new Error('A "project" must be specified to enable type checking.'); - } - - return from(this.loadTslint()).pipe(concatMap(projectTslint => new Observable(obs => { - const tslintConfigPath = options.tslintConfig - ? path.resolve(systemRoot, options.tslintConfig) - : null; - const Linter = projectTslint.Linter; - - let result: undefined | tslint.LintResult; - if (options.tsConfig) { - const tsConfigs = Array.isArray(options.tsConfig) ? options.tsConfig : [options.tsConfig]; - - for (const tsConfig of tsConfigs) { - const program = Linter.createProgram(path.resolve(systemRoot, tsConfig)); - const partial = lint(projectTslint, systemRoot, tslintConfigPath, options, program); - if (result == undefined) { - result = partial; - } else { - result.failures = result.failures - .filter(curr => !partial.failures.some(prev => curr.equals(prev))) - .concat(partial.failures); - - // we are not doing much with 'errorCount' and 'warningCount' - // apart from checking if they are greater than 0 thus no need to dedupe these. - result.errorCount += partial.errorCount; - result.warningCount += partial.warningCount; - - if (partial.fixes) { - result.fixes = result.fixes ? result.fixes.concat(partial.fixes) : partial.fixes; - } - } - } - } else { - result = lint(projectTslint, systemRoot, tslintConfigPath, options); - } - - if (result == undefined) { - throw new Error('Invalid lint configuration. Nothing to lint.'); - } - - if (!options.silent) { - const Formatter = projectTslint.findFormatter(options.format); - if (!Formatter) { - throw new Error(`Invalid lint format "${options.format}".`); - } - const formatter = new Formatter(); - - const output = formatter.format(result.failures, result.fixes); - if (output) { - this.context.logger.info(output); - } - } - - // Print formatter output directly for non human-readable formats. - if (['prose', 'verbose', 'stylish'].indexOf(options.format) == -1) { - options.silent = true; - } - - if (result.warningCount > 0 && !options.silent) { - this.context.logger.warn('Lint warnings found in the listed files.'); - } - - if (result.errorCount > 0 && !options.silent) { - this.context.logger.error('Lint errors found in the listed files.'); - } - - if (result.warningCount === 0 && result.errorCount === 0 && !options.silent) { - this.context.logger.info('All files pass linting.'); - } - - const success = options.force || result.errorCount === 0; - obs.next({ success }); - - return obs.complete(); - }))); - } -} - -function lint( - projectTslint: typeof tslint, - systemRoot: string, - tslintConfigPath: string | null, - options: TslintBuilderOptions, - program?: ts.Program, -) { - const Linter = projectTslint.Linter; - const Configuration = projectTslint.Configuration; - - const files = getFilesToLint(systemRoot, options, Linter, program); - const lintOptions = { - fix: options.fix, - formatter: options.format, - }; - - const linter = new Linter(lintOptions, program); - - let lastDirectory; - let configLoad; - for (const file of files) { - const contents = getFileContents(file, options, program); - - // Only check for a new tslint config if the path changes. - const currentDirectory = path.dirname(file); - if (currentDirectory !== lastDirectory) { - configLoad = Configuration.findConfiguration(tslintConfigPath, file); - lastDirectory = currentDirectory; - } - - if (contents && configLoad) { - linter.lint(file, contents, configLoad.results); - } - } - - return linter.getResult(); -} - -function getFilesToLint( - root: string, - options: TslintBuilderOptions, - linter: typeof tslint.Linter, - program?: ts.Program, -): string[] { - const ignore = options.exclude; - - if (options.files.length > 0) { - return options.files - .map(file => glob.sync(file, { cwd: root, ignore, nodir: true })) - .reduce((prev, curr) => prev.concat(curr), []) - .map(file => path.join(root, file)); - } - - if (!program) { - return []; - } - - let programFiles = linter.getFileNames(program); - - if (ignore && ignore.length > 0) { - // normalize to support ./ paths - const ignoreMatchers = ignore - .map(pattern => new Minimatch(path.normalize(pattern), { dot: true })); - - programFiles = programFiles - .filter(file => !ignoreMatchers.some(matcher => matcher.match(path.relative(root, file)))); - } - - return programFiles; -} - -function getFileContents( - file: string, - options: TslintBuilderOptions, - program?: ts.Program, -): string | undefined { - // The linter retrieves the SourceFile TS node directly if a program is used - if (program) { - if (program.getSourceFile(file) == undefined) { - const message = `File '${file}' is not part of the TypeScript project '${options.tsConfig}'.`; - throw new Error(message); - } - - // TODO: this return had to be commented out otherwise no file would be linted, figure out why. - // return undefined; - } - - // NOTE: The tslint CLI checks for and excludes MPEG transport streams; this does not. - try { - return stripBom(readFileSync(file, 'utf-8')); - } catch { - throw new Error(`Could not read file '${file}'.`); - } -} diff --git a/packages/angular_devkit/build_angular/src/tslint/schema.json b/packages/angular_devkit/build_angular/src/tslint/schema.json deleted file mode 100644 index cbe1d6779bff..000000000000 --- a/packages/angular_devkit/build_angular/src/tslint/schema.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "title": "TSlint Target", - "description": "TSlint target options for Build Facade.", - "type": "object", - "properties": { - "tslintConfig": { - "type": "string", - "description": "The name of the TSLint configuration file." - }, - "tsConfig": { - "description": "The name of the TypeScript configuration file.", - "oneOf": [ - { "type": "string" }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "fix": { - "type": "boolean", - "description": "Fixes linting errors (may overwrite linted files).", - "default": false - }, - "typeCheck": { - "type": "boolean", - "description": "Controls the type check for linting.", - "default": false - }, - "force": { - "type": "boolean", - "description": "Succeeds even if there was linting errors.", - "default": false - }, - "silent": { - "type": "boolean", - "description": "Show output text.", - "default": false - }, - "format": { - "type": "string", - "description": "Output format (prose, json, stylish, verbose, pmd, msbuild, checkstyle, vso, fileslist).", - "default": "prose", - "anyOf": [ - { - "enum": [ - "checkstyle", - "codeFrame", - "filesList", - "json", - "junit", - "msbuild", - "pmd", - "prose", - "stylish", - "tap", - "verbose", - "vso" - ] - }, - { "minLength": 1 } - ] - }, - "exclude": { - "type": "array", - "description": "Files to exclude from linting.", - "default": [], - "items": { - "type": "string" - } - }, - "files": { - "type": "array", - "description": "Files to include in linting.", - "default": [], - "items": { - "type": "string" - } - } - }, - "additionalProperties": false, - "required": [] -} \ No newline at end of file diff --git a/packages/angular_devkit/build_angular/src/utils/action-executor.ts b/packages/angular_devkit/build_angular/src/utils/action-executor.ts new file mode 100644 index 000000000000..716729fe86ed --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/action-executor.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import Piscina from 'piscina'; +import { InlineOptions } from './bundle-inline-options'; +import { maxWorkers } from './environment-options'; +import { I18nOptions } from './i18n-webpack'; + +const workerFile = require.resolve('./process-bundle'); + +export class BundleActionExecutor { + private workerPool?: Piscina; + + constructor(private workerOptions: { i18n: I18nOptions }) {} + + private ensureWorkerPool(): Piscina { + if (this.workerPool) { + return this.workerPool; + } + + this.workerPool = new Piscina({ + filename: workerFile, + name: 'inlineLocales', + workerData: this.workerOptions, + maxThreads: maxWorkers, + recordTiming: false, + }); + + return this.workerPool; + } + + async inline( + action: InlineOptions, + ): Promise<{ file: string; diagnostics: { type: string; message: string }[]; count: number }> { + return this.ensureWorkerPool().run(action, { name: 'inlineLocales' }); + } + + inlineAll(actions: Iterable) { + return BundleActionExecutor.executeAll(actions, (action) => this.inline(action)); + } + + private static async *executeAll( + actions: Iterable, + executor: (action: I) => Promise, + ): AsyncIterable { + const executions = new Map, Promise<[Promise, O]>>(); + for (const action of actions) { + const execution = executor(action); + executions.set( + execution, + execution.then((result) => [execution, result]), + ); + } + + while (executions.size > 0) { + const [execution, result] = await Promise.race(executions.values()); + executions.delete(execution); + yield result; + } + } + + stop(): void { + if (this.workerPool) { + void this.workerPool.destroy(); + } + } +} diff --git a/packages/angular_devkit/build_angular/src/utils/build-options.ts b/packages/angular_devkit/build_angular/src/utils/build-options.ts new file mode 100644 index 000000000000..9ffd13f517d3 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/build-options.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { ParsedConfiguration } from '@angular/compiler-cli'; +import { logging } from '@angular-devkit/core'; +import { + AssetPatternClass, + Budget, + CrossOrigin, + I18NTranslation, + IndexUnion, + InlineStyleLanguage, + Localize, + OutputHashing, + ScriptElement, + SourceMapClass, + StyleElement, +} from '../builders/browser/schema'; +import { Schema as DevServerSchema } from '../builders/dev-server/schema'; +import { NormalizedCachedOptions } from './normalize-cache'; +import { NormalizedFileReplacement } from './normalize-file-replacements'; +import { NormalizedOptimizationOptions } from './normalize-optimization'; + +export interface BuildOptions { + optimization: NormalizedOptimizationOptions; + environment?: string; + outputPath: string; + resourcesOutputPath?: string; + aot?: boolean; + sourceMap: SourceMapClass; + vendorChunk?: boolean; + commonChunk?: boolean; + baseHref?: string; + deployUrl?: string; + verbose?: boolean; + progress?: boolean; + localize?: Localize; + i18nMissingTranslation?: I18NTranslation; + externalDependencies?: string[]; + watch?: boolean; + outputHashing?: OutputHashing; + poll?: number; + index?: IndexUnion; + deleteOutputPath?: boolean; + preserveSymlinks?: boolean; + extractLicenses?: boolean; + buildOptimizer?: boolean; + namedChunks?: boolean; + crossOrigin?: CrossOrigin; + subresourceIntegrity?: boolean; + serviceWorker?: boolean; + webWorkerTsConfig?: string; + statsJson: boolean; + hmr?: boolean; + main: string; + polyfills: string[]; + budgets: Budget[]; + assets: AssetPatternClass[]; + scripts: ScriptElement[]; + styles: StyleElement[]; + stylePreprocessorOptions?: { includePaths: string[] }; + platform?: 'browser' | 'server'; + fileReplacements: NormalizedFileReplacement[]; + inlineStyleLanguage?: InlineStyleLanguage; + allowedCommonJsDependencies?: string[]; + cache: NormalizedCachedOptions; + codeCoverage?: boolean; + codeCoverageExclude?: string[]; + supportedBrowsers?: string[]; +} + +export interface WebpackDevServerOptions + extends BuildOptions, + Omit {} + +export interface WebpackConfigOptions { + root: string; + logger: logging.Logger; + projectRoot: string; + sourceRoot?: string; + buildOptions: T; + tsConfig: ParsedConfiguration; + tsConfigPath: string; + projectName: string; +} diff --git a/packages/angular_devkit/build_angular/src/utils/bundle-inline-options.ts b/packages/angular_devkit/build_angular/src/utils/bundle-inline-options.ts new file mode 100644 index 000000000000..68c53b06777c --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/bundle-inline-options.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export interface InlineOptions { + filename: string; + code: string; + map?: string; + outputPath: string; + missingTranslation?: 'warning' | 'error' | 'ignore'; + setLocale?: boolean; +} diff --git a/packages/angular_devkit/build_angular/src/utils/color.ts b/packages/angular_devkit/build_angular/src/utils/color.ts new file mode 100644 index 000000000000..3e6eac3c63c8 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/color.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import * as ansiColors from 'ansi-colors'; +import { WriteStream } from 'node:tty'; + +function supportColor(): boolean { + if (process.env.FORCE_COLOR !== undefined) { + // 2 colors: FORCE_COLOR = 0 (Disables colors), depth 1 + // 16 colors: FORCE_COLOR = 1, depth 4 + // 256 colors: FORCE_COLOR = 2, depth 8 + // 16,777,216 colors: FORCE_COLOR = 3, depth 16 + // See: https://nodejs.org/dist/latest-v12.x/docs/api/tty.html#tty_writestream_getcolordepth_env + // and https://github.com/nodejs/node/blob/b9f36062d7b5c5039498e98d2f2c180dca2a7065/lib/internal/tty.js#L106; + switch (process.env.FORCE_COLOR) { + case '': + case 'true': + case '1': + case '2': + case '3': + return true; + default: + return false; + } + } + + if (process.stdout instanceof WriteStream) { + return process.stdout.hasColors(); + } + + return false; +} + +// Create a separate instance to prevent unintended global changes to the color configuration +const colors = ansiColors.create(); +colors.enabled = supportColor(); + +export { colors }; diff --git a/packages/angular_devkit/build_angular/src/utils/copy-assets.ts b/packages/angular_devkit/build_angular/src/utils/copy-assets.ts new file mode 100644 index 000000000000..a525cab99b34 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/copy-assets.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { glob } from 'tinyglobby'; + +export async function copyAssets( + entries: { + glob: string; + ignore?: string[]; + input: string; + output: string; + flatten?: boolean; + followSymlinks?: boolean; + }[], + basePaths: Iterable, + root: string, + changed?: Set, +) { + const defaultIgnore = ['.gitkeep', '**/.DS_Store', '**/Thumbs.db']; + + const outputFiles: { source: string; destination: string }[] = []; + + for (const entry of entries) { + const cwd = path.resolve(root, entry.input); + const files = await glob(entry.glob, { + cwd, + dot: true, + ignore: entry.ignore ? defaultIgnore.concat(entry.ignore) : defaultIgnore, + followSymbolicLinks: entry.followSymlinks ?? false, + }); + + const directoryExists = new Set(); + + for (const file of files) { + const src = path.join(cwd, file); + if (changed && !changed.has(src)) { + continue; + } + + const filePath = entry.flatten ? path.basename(file) : file; + + outputFiles.push({ source: src, destination: path.join(entry.output, filePath) }); + + for (const base of basePaths) { + const dest = path.join(base, entry.output, filePath); + const dir = path.dirname(dest); + if (!directoryExists.has(dir)) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + directoryExists.add(dir); + } + fs.copyFileSync(src, dest, fs.constants.COPYFILE_FICLONE); + } + } + } + + return outputFiles; +} diff --git a/packages/angular_devkit/build_angular/src/utils/default-progress.ts b/packages/angular_devkit/build_angular/src/utils/default-progress.ts index 258412b460f1..a924a2fa9689 100644 --- a/packages/angular_devkit/build_angular/src/utils/default-progress.ts +++ b/packages/angular_devkit/build_angular/src/utils/default-progress.ts @@ -1,9 +1,9 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ export function defaultProgress(progress: boolean | undefined): boolean { diff --git a/packages/angular_devkit/build_angular/src/utils/environment-options.ts b/packages/angular_devkit/build_angular/src/utils/environment-options.ts new file mode 100644 index 000000000000..9d53be82ce79 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/environment-options.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { availableParallelism } from 'node:os'; + +function isDisabled(variable: string): boolean { + return variable === '0' || variable.toLowerCase() === 'false'; +} + +function isEnabled(variable: string): boolean { + return variable === '1' || variable.toLowerCase() === 'true'; +} + +function isPresent(variable: string | undefined): variable is string { + return typeof variable === 'string' && variable !== ''; +} + +// Optimization and mangling +const debugOptimizeVariable = process.env['NG_BUILD_DEBUG_OPTIMIZE']; +const debugOptimize = (() => { + if (!isPresent(debugOptimizeVariable) || isDisabled(debugOptimizeVariable)) { + return { + mangle: true, + minify: true, + beautify: false, + }; + } + + const debugValue = { + mangle: false, + minify: false, + beautify: true, + }; + + if (isEnabled(debugOptimizeVariable)) { + return debugValue; + } + + for (const part of debugOptimizeVariable.split(',')) { + switch (part.trim().toLowerCase()) { + case 'mangle': + debugValue.mangle = true; + break; + case 'minify': + debugValue.minify = true; + break; + case 'beautify': + debugValue.beautify = true; + break; + } + } + + return debugValue; +})(); + +const mangleVariable = process.env['NG_BUILD_MANGLE']; +export const allowMangle = isPresent(mangleVariable) + ? !isDisabled(mangleVariable) + : debugOptimize.mangle; + +export const shouldBeautify = debugOptimize.beautify; +export const allowMinify = debugOptimize.minify; + +/** + * Some environments, like CircleCI which use Docker report a number of CPUs by the host and not the count of available. + * This cause `Error: Call retries were exceeded` errors when trying to use them. + * + * @see https://github.com/nodejs/node/issues/28762 + * @see https://github.com/webpack-contrib/terser-webpack-plugin/issues/143 + * @see https://ithub.com/angular/angular-cli/issues/16860#issuecomment-588828079 + * + */ +const maxWorkersVariable = process.env['NG_BUILD_MAX_WORKERS']; +export const maxWorkers = isPresent(maxWorkersVariable) + ? +maxWorkersVariable + : Math.min(4, Math.max(availableParallelism() - 1, 1)); + +const parallelTsVariable = process.env['NG_BUILD_PARALLEL_TS']; +export const useParallelTs = !isPresent(parallelTsVariable) || !isDisabled(parallelTsVariable); + +const debugPerfVariable = process.env['NG_BUILD_DEBUG_PERF']; +export const debugPerformance = isPresent(debugPerfVariable) && isEnabled(debugPerfVariable); + +const watchRootVariable = process.env['NG_BUILD_WATCH_ROOT']; +export const shouldWatchRoot = isPresent(watchRootVariable) && isEnabled(watchRootVariable); + +const typeCheckingVariable = process.env['NG_BUILD_TYPE_CHECK']; +export const useTypeChecking = + !isPresent(typeCheckingVariable) || !isDisabled(typeCheckingVariable); + +const buildLogsJsonVariable = process.env['NG_BUILD_LOGS_JSON']; +export const useJSONBuildLogs = + isPresent(buildLogsJsonVariable) && isEnabled(buildLogsJsonVariable); diff --git a/packages/angular_devkit/build_angular/src/utils/error.ts b/packages/angular_devkit/build_angular/src/utils/error.ts new file mode 100644 index 000000000000..0ca77c331d2d --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/error.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; + +export function assertIsError(value: unknown): asserts value is Error & { code?: string } { + const isError = + value instanceof Error || + // The following is needing to identify errors coming from RxJs. + (typeof value === 'object' && value && 'name' in value && 'message' in value); + assert(isError, 'catch clause variable is not an Error instance'); +} diff --git a/packages/angular_devkit/build_angular/src/utils/i18n-inlining.ts b/packages/angular_devkit/build_angular/src/utils/i18n-inlining.ts new file mode 100644 index 000000000000..9f639f16cb21 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/i18n-inlining.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderContext } from '@angular-devkit/architect'; +import { EmittedFiles } from '@angular-devkit/build-webpack'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { BundleActionExecutor } from './action-executor'; +import { InlineOptions } from './bundle-inline-options'; +import { copyAssets } from './copy-assets'; +import { assertIsError } from './error'; +import { I18nOptions } from './i18n-webpack'; +import { Spinner } from './spinner'; + +function emittedFilesToInlineOptions( + emittedFiles: EmittedFiles[], + scriptsEntryPointName: string[], + emittedPath: string, + outputPath: string, + missingTranslation: 'error' | 'warning' | 'ignore' | undefined, + context: BuilderContext, +): { options: InlineOptions[]; originalFiles: string[] } { + const options: InlineOptions[] = []; + const originalFiles: string[] = []; + for (const emittedFile of emittedFiles) { + if ( + emittedFile.asset || + emittedFile.extension !== '.js' || + (emittedFile.name && scriptsEntryPointName.includes(emittedFile.name)) + ) { + continue; + } + + const originalPath = path.join(emittedPath, emittedFile.file); + const action: InlineOptions = { + filename: emittedFile.file, + code: fs.readFileSync(originalPath, 'utf8'), + outputPath, + missingTranslation, + setLocale: emittedFile.name === 'main', + }; + originalFiles.push(originalPath); + + try { + const originalMapPath = originalPath + '.map'; + action.map = fs.readFileSync(originalMapPath, 'utf8'); + originalFiles.push(originalMapPath); + } catch (err) { + assertIsError(err); + if (err.code !== 'ENOENT') { + throw err; + } + } + + context.logger.debug(`i18n file queued for processing: ${action.filename}`); + + options.push(action); + } + + return { options, originalFiles }; +} + +export async function i18nInlineEmittedFiles( + context: BuilderContext, + emittedFiles: EmittedFiles[], + i18n: I18nOptions, + baseOutputPath: string, + outputPaths: string[], + scriptsEntryPointName: string[], + emittedPath: string, + missingTranslation: 'error' | 'warning' | 'ignore' | undefined, +): Promise { + const executor = new BundleActionExecutor({ i18n }); + let hasErrors = false; + const spinner = new Spinner(); + spinner.start('Generating localized bundles...'); + + try { + const { options, originalFiles: processedFiles } = emittedFilesToInlineOptions( + emittedFiles, + scriptsEntryPointName, + emittedPath, + baseOutputPath, + missingTranslation, + context, + ); + + for await (const result of executor.inlineAll(options)) { + context.logger.debug(`i18n file processed: ${result.file}`); + + for (const diagnostic of result.diagnostics) { + spinner.stop(); + if (diagnostic.type === 'error') { + hasErrors = true; + context.logger.error(diagnostic.message); + } else { + context.logger.warn(diagnostic.message); + } + spinner.start(); + } + } + + // Copy any non-processed files into the output locations + await copyAssets( + [ + { + glob: '**/*', + input: emittedPath, + output: '', + ignore: [...processedFiles].map((f) => path.relative(emittedPath, f)), + }, + ], + outputPaths, + '', + ); + } catch (err) { + assertIsError(err); + spinner.fail('Localized bundle generation failed: ' + err.message); + + return false; + } finally { + executor.stop(); + } + + if (hasErrors) { + spinner.fail('Localized bundle generation failed.'); + } else { + spinner.succeed('Localized bundle generation complete.'); + } + + return !hasErrors; +} diff --git a/packages/angular_devkit/build_angular/src/utils/i18n-webpack.ts b/packages/angular_devkit/build_angular/src/utils/i18n-webpack.ts new file mode 100644 index 000000000000..ef0fd1dc2207 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/i18n-webpack.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + type I18nOptions, + createI18nOptions, + createTranslationLoader, + loadTranslations, +} from '@angular/build/private'; +import { BuilderContext } from '@angular-devkit/architect'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { Schema as BrowserBuilderSchema } from '../builders/browser/schema'; +import { Schema as ServerBuilderSchema } from '../builders/server/schema'; +import { readTsconfig } from '../utils/read-tsconfig'; + +/** + * The base module location used to search for locale specific data. + */ +const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global'; + +// Re-export for use within Webpack related builders +export { I18nOptions, loadTranslations }; + +export async function configureI18nBuild( + context: BuilderContext, + options: T, +): Promise<{ + buildOptions: T; + i18n: I18nOptions; +}> { + if (!context.target) { + throw new Error('The builder requires a target.'); + } + + const buildOptions = { ...options }; + const tsConfig = await readTsconfig(buildOptions.tsConfig, context.workspaceRoot); + const metadata = await context.getProjectMetadata(context.target); + const i18n = createI18nOptions(metadata, buildOptions.localize, context.logger); + + // No additional processing needed if no inlining requested and no source locale defined. + if (!i18n.shouldInline && !i18n.hasDefinedSourceLocale) { + return { buildOptions, i18n }; + } + + const projectRoot = path.join(context.workspaceRoot, (metadata.root as string) || ''); + // The trailing slash is required to signal that the path is a directory and not a file. + const projectRequire = createRequire(projectRoot + '/'); + const localeResolver = (locale: string) => + projectRequire.resolve(path.join(LOCALE_DATA_BASE_MODULE, locale)); + + // Load locale data and translations (if present) + let loader; + const usedFormats = new Set(); + for (const [locale, desc] of Object.entries(i18n.locales)) { + if (!i18n.inlineLocales.has(locale) && locale !== i18n.sourceLocale) { + continue; + } + + let localeDataPath = findLocaleDataPath(locale, localeResolver); + if (!localeDataPath) { + const [first] = locale.split('-'); + if (first) { + localeDataPath = findLocaleDataPath(first.toLowerCase(), localeResolver); + if (localeDataPath) { + context.logger.warn( + `Locale data for '${locale}' cannot be found. Using locale data for '${first}'.`, + ); + } + } + } + if (!localeDataPath) { + context.logger.warn( + `Locale data for '${locale}' cannot be found. No locale data will be included for this locale.`, + ); + } else { + desc.dataPath = localeDataPath; + } + + if (!desc.files.length) { + continue; + } + + loader ??= await createTranslationLoader(); + + loadTranslations( + locale, + desc, + context.workspaceRoot, + loader, + { + warn(message) { + context.logger.warn(message); + }, + error(message) { + throw new Error(message); + }, + }, + usedFormats, + buildOptions.i18nDuplicateTranslation, + ); + + if (usedFormats.size > 1 && tsConfig.options.enableI18nLegacyMessageIdFormat !== false) { + // This limitation is only for legacy message id support (defaults to true as of 9.0) + throw new Error( + 'Localization currently only supports using one type of translation file format for the entire application.', + ); + } + } + + // If inlining store the output in a temporary location to facilitate post-processing + if (i18n.shouldInline) { + // TODO: we should likely save these in the .angular directory in the next major version. + // We'd need to do a migration to add the temp directory to gitignore. + const tempPath = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'angular-cli-i18n-')); + buildOptions.outputPath = tempPath; + + process.on('exit', () => { + try { + fs.rmSync(tempPath, { force: true, recursive: true, maxRetries: 3 }); + } catch {} + }); + } + + return { buildOptions, i18n }; +} + +function findLocaleDataPath(locale: string, resolver: (locale: string) => string): string | null { + // Remove private use subtags + const scrubbedLocale = locale.replace(/-x(-[a-zA-Z0-9]{1,8})+$/, ''); + + try { + return resolver(scrubbedLocale); + } catch { + // fallback to known existing en-US locale data as of 14.0 + return scrubbedLocale === 'en-US' ? findLocaleDataPath('en', resolver) : null; + } +} diff --git a/packages/angular_devkit/build_angular/src/utils/index.ts b/packages/angular_devkit/build_angular/src/utils/index.ts index be8b36d7091a..da07543fafa8 100644 --- a/packages/angular_devkit/build_angular/src/utils/index.ts +++ b/packages/angular_devkit/build_angular/src/utils/index.ts @@ -1,12 +1,16 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ export * from './default-progress'; -export * from './run-module-as-observable-fork'; +export { deleteOutputDir, loadProxyConfiguration } from '@angular/build/private'; export * from './normalize-file-replacements'; export * from './normalize-asset-patterns'; +export * from './normalize-source-maps'; +export * from './normalize-optimization'; +export * from './normalize-builder-schema'; +export * from './url'; diff --git a/packages/angular_devkit/build_angular/src/utils/inline-critical-css.ts b/packages/angular_devkit/build_angular/src/utils/inline-critical-css.ts new file mode 100644 index 000000000000..cd58ea795480 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/inline-critical-css.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readFile } from 'node:fs/promises'; + +export interface InlineCriticalCssProcessorOptions { + minify?: boolean; + deployUrl?: string; + readAsset?: (path: string) => Promise; + autoCsp?: boolean; + outputPath?: string; +} + +export class InlineCriticalCssProcessor { + constructor(protected readonly options: InlineCriticalCssProcessorOptions) {} + + async process(html: string): Promise<{ content: string; warnings: string[]; errors: string[] }> { + const warnings: string[] = []; + const errors: string[] = []; + const { outputPath, deployUrl, minify = false, readAsset } = this.options; + + const { default: Beasties } = await import('beasties'); + + const beasties = new Beasties({ + logger: { + warn: (s: string) => warnings.push(s), + error: (s: string) => errors.push(s), + info: () => {}, + }, + logLevel: 'warn', + path: outputPath, + publicPath: deployUrl, + compress: minify, + pruneSource: false, + reduceInlineStyles: false, + mergeStylesheets: false, + preload: 'media-script', + nonce: (document) => { + const nonceElement = document.querySelector('[ngCspNonce], [ngcspnonce]'); + const cspNonce = + nonceElement?.getAttribute('ngCspNonce') || nonceElement?.getAttribute('ngcspnonce'); + + return cspNonce ?? undefined; + }, + noscriptFallback: true, + inlineFonts: true, + }); + + beasties.readFile = (path) => { + return readAsset ? readAsset(path) : readFile(path, 'utf-8'); + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const beastiesInternal = beasties as any; + const initialEmbedLinkedStylesheet = + beastiesInternal.embedLinkedStylesheet.bind(beastiesInternal); + beastiesInternal.embedLinkedStylesheet = async (link: unknown, document: unknown) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const linkEl = link as any; + const beastiesMedia = linkEl.getAttribute('data-beasties-media'); + if (beastiesMedia) { + linkEl.removeAttribute('data-beasties-media'); + linkEl.setAttribute('media', beastiesMedia); + if (linkEl.next?.name === 'noscript') { + linkEl.next.remove?.(); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (document as any).querySelectorAll('script').forEach((script: any) => { + if (script.textContent?.includes('data-beasties-media')) { + script.remove?.(); + } + }); + } + + return initialEmbedLinkedStylesheet(link, document); + }; + const content = await beasties.process(html); + + return { + // Clean up value from value less attributes. + // This is caused because parse5 always requires attributes to have a string value. + // nomodule="" defer="" -> nomodule defer. + content: content.replace(/(\s(?:defer|nomodule))=""/g, '$1'), + errors, + warnings, + }; + } +} diff --git a/packages/angular_devkit/build_angular/src/utils/inline-critical-css_spec.ts b/packages/angular_devkit/build_angular/src/utils/inline-critical-css_spec.ts new file mode 100644 index 000000000000..a5a49baec26b --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/inline-critical-css_spec.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { tags } from '@angular-devkit/core'; +import { InlineCriticalCssProcessor } from './inline-critical-css'; + +describe('InlineCriticalCssProcessor', () => { + const styles: Record = { + '/dist/styles.css': ` + body { margin: 0; } + html { color: white; } + `, + '/dist/theme.css': ` + span { color: blue; } + p { color: blue; } + `, + }; + + const readAsset = async (file: string): Promise => { + const content = styles[file]; + if (content) { + return content; + } + + throw new Error('Cannot read asset.'); + }; + + const getContent = (deployUrl: string, bodyContent = ''): string => { + return ` + + + + + + ${bodyContent} + `; + }; + + it('should inline critical css', async () => { + const inlineFontsProcessor = new InlineCriticalCssProcessor({ + readAsset, + outputPath: '/dist/', + }); + + const { content } = await inlineFontsProcessor.process(getContent('')); + + expect(content).toContain( + ``, + ); + expect(content).toContain( + ``, + ); + expect(content).not.toContain('color: blue'); + expect(tags.stripIndents`${content}`).toContain(tags.stripIndents` + `); + }); + + it('should inline critical css when using deployUrl', async () => { + const inlineFontsProcessor = new InlineCriticalCssProcessor({ + readAsset, + outputPath: '/dist/', + deployUrl: 'http://cdn.com', + }); + + const { content } = await inlineFontsProcessor.process(getContent('http://cdn.com/')); + + expect(content).toContain( + ``, + ); + expect(content).toContain( + ``, + ); + expect(tags.stripIndents`${content}`).toContain(tags.stripIndents` + `); + }); + + it('should compress inline critical css when minify is enabled', async () => { + const inlineFontsProcessor = new InlineCriticalCssProcessor({ + readAsset, + minify: true, + outputPath: '/dist/', + }); + + const { content } = await inlineFontsProcessor.process(getContent('')); + + expect(content).toContain( + ``, + ); + expect(content).toContain( + ``, + ); + expect(content).toContain(''); + }); + + it(`should process the inline 'onload' handlers if a 'autoCsp' is true`, async () => { + const inlineCssProcessor = new InlineCriticalCssProcessor({ + readAsset, + autoCsp: true, + outputPath: '/dist/', + }); + + const { content } = await inlineCssProcessor.process(getContent('')); + + expect(content).toContain( + '', + ); + expect(tags.stripIndents`${content}`).toContain(tags.stripIndents` + `); + }); + + it('should process the inline `onload` handlers if a CSP nonce is specified', async () => { + const inlineCssProcessor = new InlineCriticalCssProcessor({ + readAsset, + outputPath: '/dist/', + }); + + const { content } = await inlineCssProcessor.process( + getContent('', ''), + ); + + expect(content).toContain( + '', + ); + expect(content).toContain( + '', + ); + // Nonces shouldn't be added inside the `noscript` tags. + expect(content).toContain(''); + expect(content).toContain('`); - }), - ).toPromise().then(done, done.fail); - }); - - it('works with UTF16 LE BOM', (done) => { - host.writeMultipleFiles({ - 'src/index.html': Buffer.from( - '\ufeff', - 'utf16le'), - }); - - runTargetSpec(host, browserTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const fileName = join(outputPath, 'index.html'); - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - // tslint:disable-next-line:max-line-length - expect(content).toBe(``); - }), - ).toPromise().then(done, done.fail); - }); - - it('keeps escaped charaters', (done) => { - host.writeMultipleFiles({ - 'src/index.html': tags.oneLine` - í - - `, - }); - - runTargetSpec(host, browserTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const fileName = join(outputPath, 'index.html'); - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - // tslint:disable-next-line:max-line-length - expect(content).toBe(`í `); - }), - ).toPromise().then(done, done.fail); - }); - - it('keeps custom template charaters', (done) => { - host.writeMultipleFiles({ - 'src/index.html': tags.oneLine` - <%= csrf_meta_tags %> - - `, - }); - - runTargetSpec(host, browserTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const fileName = join(outputPath, 'index.html'); - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - // tslint:disable-next-line:max-line-length - expect(content).toBe(`<%= csrf_meta_tags %> `); - }), - ).toPromise().then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/lazy-module_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/lazy-module_spec_large.ts deleted file mode 100644 index c6dbf303f186..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/lazy-module_spec_large.ts +++ /dev/null @@ -1,308 +0,0 @@ -/** - * @license - * 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 - */ - -import { DefaultTimeout, TestLogger, runTargetSpec } from '@angular-devkit/architect/testing'; -import { join, normalize } from '@angular-devkit/core'; -import { take, tap } from 'rxjs/operators'; -import { BrowserBuilderSchema } from '../../src'; -import { browserTargetSpec, host } from '../utils'; - - -export const lazyModuleFiles: { [path: string]: string } = { - 'src/app/lazy/lazy-routing.module.ts': ` - import { NgModule } from '@angular/core'; - import { Routes, RouterModule } from '@angular/router'; - - const routes: Routes = []; - - @NgModule({ - imports: [RouterModule.forChild(routes)], - exports: [RouterModule] - }) - export class LazyRoutingModule { } - `, - 'src/app/lazy/lazy.module.ts': ` - import { NgModule } from '@angular/core'; - import { CommonModule } from '@angular/common'; - - import { LazyRoutingModule } from './lazy-routing.module'; - - @NgModule({ - imports: [ - CommonModule, - LazyRoutingModule - ], - declarations: [] - }) - export class LazyModule { } - `, -}; - -export const lazyModuleImport: { [path: string]: string } = { - 'src/app/app.module.ts': ` - import { BrowserModule } from '@angular/platform-browser'; - import { NgModule } from '@angular/core'; - import { HttpModule } from '@angular/http'; - - import { AppComponent } from './app.component'; - import { RouterModule } from '@angular/router'; - - @NgModule({ - declarations: [ - AppComponent - ], - imports: [ - BrowserModule, - HttpModule, - RouterModule.forRoot([ - { path: 'lazy', loadChildren: './lazy/lazy.module#LazyModule' } - ]) - ], - providers: [], - bootstrap: [AppComponent] - }) - export class AppModule { } - `, -}; - -// tslint:disable-next-line:no-big-function -describe('Browser Builder lazy modules', () => { - - const outputPath = normalize('dist'); - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('supports lazy bundle for lazy routes with JIT', (done) => { - host.writeMultipleFiles(lazyModuleFiles); - host.writeMultipleFiles(lazyModuleImport); - - runTargetSpec(host, browserTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - expect(host.scopedSync().exists(join(outputPath, 'lazy-lazy-module.js'))).toBe(true); - }), - ).toPromise().then(done, done.fail); - }); - - it('should show error when lazy route is invalid on watch mode AOT', (done) => { - host.writeMultipleFiles(lazyModuleFiles); - host.writeMultipleFiles(lazyModuleImport); - host.replaceInFile( - 'src/app/app.module.ts', - 'lazy.module#LazyModule', - 'invalid.module#LazyModule', - ); - - const logger = new TestLogger('rebuild-lazy-errors'); - const overrides = { watch: true, aot: true }; - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout, logger).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(false)), - tap(() => { - expect(logger.includes('Could not resolve module')).toBe(true); - logger.clear(); - host.appendToFile('src/main.ts', ' '); - }), - take(2), - ).toPromise().then(done, done.fail); - }); - - it('should show error when lazy route is invalid on watch mode JIT', (done) => { - host.writeMultipleFiles(lazyModuleFiles); - host.writeMultipleFiles(lazyModuleImport); - host.replaceInFile( - 'src/app/app.module.ts', - 'lazy.module#LazyModule', - 'invalid.module#LazyModule', - ); - - const logger = new TestLogger('rebuild-lazy-errors'); - const overrides = { watch: true, aot: false }; - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout, logger).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(false)), - tap(() => { - expect(logger.includes('Could not resolve module')).toBe(true); - logger.clear(); - host.appendToFile('src/main.ts', ' '); - }), - take(2), - ).toPromise().then(done, done.fail); - }); - - it('supports lazy bundle for lazy routes with AOT', (done) => { - host.writeMultipleFiles(lazyModuleFiles); - host.writeMultipleFiles(lazyModuleImport); - - runTargetSpec(host, browserTargetSpec, { aot: true }).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - expect(host.scopedSync() - .exists(join(outputPath, 'lazy-lazy-module.js'))).toBe(true); - }), - ).toPromise().then(done, done.fail); - }); - - it(`supports lazy bundle for import() calls`, (done) => { - host.writeMultipleFiles({ - 'src/lazy-module.ts': 'export const value = 42;', - 'src/main.ts': `import('./lazy-module');`, - }); - // Using `import()` in TS require targetting `esnext` modules. - host.replaceInFile('src/tsconfig.app.json', `"module": "es2015"`, `"module": "esnext"`); - - runTargetSpec(host, browserTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => expect(host.scopedSync().exists(join(outputPath, '0.js'))).toBe(true)), - ).toPromise().then(done, done.fail); - }); - - it(`supports lazy bundle for dynamic import() calls`, (done) => { - host.writeMultipleFiles({ - 'src/lazy-module.ts': 'export const value = 42;', - 'src/main.ts': ` - const lazyFileName = 'module'; - import(/*webpackChunkName: '[request]'*/'./lazy-' + lazyFileName); - `, - }); - host.replaceInFile('src/tsconfig.app.json', `"module": "es2015"`, `"module": "esnext"`); - - runTargetSpec(host, browserTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => expect(host.scopedSync().exists(join(outputPath, 'lazy-module.js'))).toBe(true)), - ).toPromise().then(done, done.fail); - }); - - it(`supports lazy bundle for System.import() calls`, (done) => { - host.writeMultipleFiles({ - 'src/lazy-module.ts': 'export const value = 42;', - 'src/main.ts': `declare var System: any; System.import('./lazy-module');`, - }); - - runTargetSpec(host, browserTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => expect(host.scopedSync().exists(join(outputPath, '0.js'))).toBe(true)), - ).toPromise().then(done, done.fail); - }); - - it(`supports hiding lazy bundle module name`, (done) => { - host.writeMultipleFiles({ - 'src/lazy-module.ts': 'export const value = 42;', - 'src/main.ts': `const lazyFileName = 'module'; import('./lazy-' + lazyFileName);`, - }); - host.replaceInFile('src/tsconfig.app.json', `"module": "es2015"`, `"module": "esnext"`); - - const overrides: Partial = { namedChunks: false }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => expect(host.scopedSync().exists(join(outputPath, '0.js'))).toBe(true)), - ).toPromise().then(done, done.fail); - }); - - it(`supports making a common bundle for shared lazy modules`, (done) => { - host.writeMultipleFiles({ - 'src/one.ts': `import * as http from '@angular/http'; console.log(http);`, - 'src/two.ts': `import * as http from '@angular/http'; console.log(http);`, - 'src/main.ts': `import('./one'); import('./two');`, - }); - host.replaceInFile('src/tsconfig.app.json', `"module": "es2015"`, `"module": "esnext"`); - - runTargetSpec(host, browserTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => expect(host.scopedSync().exists(join(outputPath, '0.js'))).toBe(true)), - tap(() => expect(host.scopedSync().exists(join(outputPath, '1.js'))).toBe(true)), - // TODO: the chunk with common modules used to be called `common`, see why that changed. - tap(() => expect(host.scopedSync().exists(join(outputPath, '2.js'))).toBe(true)), - ).toPromise().then(done, done.fail); - }); - - it(`supports disabling the common bundle`, (done) => { - host.writeMultipleFiles({ - 'src/one.ts': `import * as http from '@angular/http'; console.log(http);`, - 'src/two.ts': `import * as http from '@angular/http'; console.log(http);`, - 'src/main.ts': `import('./one'); import('./two');`, - }); - host.replaceInFile('src/tsconfig.app.json', `"module": "es2015"`, `"module": "esnext"`); - - const overrides: Partial = { commonChunk: false }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => expect(host.scopedSync().exists(join(outputPath, '0.js'))).toBe(true)), - tap(() => expect(host.scopedSync().exists(join(outputPath, '1.js'))).toBe(true)), - tap(() => expect(host.scopedSync().exists(join(outputPath, '2.js'))).toBe(false)), - ).toPromise().then(done, done.fail); - }); - - it(`supports extra lazy modules array in JIT`, (done) => { - host.writeMultipleFiles(lazyModuleFiles); - host.writeMultipleFiles({ - 'src/app/app.component.ts': ` - import { Component, SystemJsNgModuleLoader } from '@angular/core'; - - @Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.css'], - }) - export class AppComponent { - title = 'app'; - constructor(loader: SystemJsNgModuleLoader) { - // Module will be split at build time and loaded when requested below - loader.load('src/app/lazy/lazy.module#LazyModule') - .then((factory) => { /* Use factory here */ }); - } - }`, - }); - host.replaceInFile('src/tsconfig.app.json', `"module": "es2015"`, `"module": "esnext"`); - - const overrides: Partial = { lazyModules: ['src/app/lazy/lazy.module'] }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => expect(host.scopedSync().exists(join(outputPath, 'src-app-lazy-lazy-module.js'))) - .toBe(true)), - ).toPromise().then(done, done.fail); - }); - - it(`supports extra lazy modules array in AOT`, (done) => { - host.writeMultipleFiles(lazyModuleFiles); - host.writeMultipleFiles({ - 'src/app/app.component.ts': ` - import { Component, SystemJsNgModuleLoader } from '@angular/core'; - - @Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.css'], - }) - export class AppComponent { - title = 'app'; - constructor(loader: SystemJsNgModuleLoader) { - // Module will be split at build time and loaded when requested below - loader.load('src/app/lazy/lazy.module#LazyModule') - .then((factory) => { /* Use factory here */ }); - } - }`, - }); - host.replaceInFile('src/tsconfig.app.json', `"module": "es2015"`, `"module": "esnext"`); - - const overrides: Partial = { - lazyModules: ['src/app/lazy/lazy.module'], - aot: true, - optimization: true, - }; - - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 2).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => expect(host.scopedSync() - .exists(join(outputPath, 'src-app-lazy-lazy-module.js'))) - .toBe(true)), - ).toPromise().then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/license-extraction_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/license-extraction_spec_large.ts deleted file mode 100644 index 3dbea255bf73..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/license-extraction_spec_large.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * @license - * 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 - */ - -import { DefaultTimeout, runTargetSpec } from '@angular-devkit/architect/testing'; -import { join, normalize } from '@angular-devkit/core'; -import { tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; - - -describe('Browser Builder license extraction', () => { - const outputPath = normalize('dist'); - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - // Ignored because license works when trying manually on a project, but doesn't work here. - // TODO: fix VFS use in webpack and the test host, and reenable this test. - xit('works', (done) => { - // TODO: make license extraction independent from optimization level. - const overrides = { extractLicenses: true, optimization: true }; - - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 2).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const fileName = join(outputPath, '3rdpartylicenses.txt'); - expect(host.scopedSync().exists(fileName)).toBe(true); - }), - ).toPromise().then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/no-entry-module_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/no-entry-module_spec_large.ts deleted file mode 100644 index ffb086befbd6..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/no-entry-module_spec_large.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @license - * 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 - */ - -import { TestLogger, runTargetSpec } from '@angular-devkit/architect/testing'; -import { tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; - - -describe('Browser Builder no entry module', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - // Remove the bootstrap but keep a reference to AppModule so the import is not elided. - host.replaceInFile('src/main.ts', /platformBrowserDynamic.*?bootstrapModule.*?;/, ''); - host.appendToFile('src/main.ts', 'console.log(AppModule);'); - - const overrides = { baseHref: '/myUrl' }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }); - - it('reports warning when no bootstrap code', (done) => { - host.replaceInFile('src/main.ts', /./g, ''); - host.appendToFile('src/main.ts', `import './app/app.module';`); - - const logger = new TestLogger('no-bootstrap'); - runTargetSpec(host, browserTargetSpec, undefined, undefined, logger).pipe( - tap(() => expect(logger.includes('Lazy routes discovery is not enabled')).toBe(true)), - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/optimization-level_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/optimization-level_spec_large.ts deleted file mode 100644 index 932ae0928baa..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/optimization-level_spec_large.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @license - * 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 - */ - -import { DefaultTimeout, runTargetSpec } from '@angular-devkit/architect/testing'; -import { join, normalize, virtualFs } from '@angular-devkit/core'; -import { tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; - - -describe('Browser Builder optimization level', () => { - const outputPath = normalize('dist'); - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - const overrides = { optimization: true }; - - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 2).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const fileName = join(outputPath, 'main.js'); - const content = virtualFs.fileBufferToString(host.scopedSync().read(fileName)); - // Bundle contents should be uglified, which includes variable mangling. - expect(content).not.toContain('AppComponent'); - }), - ).toPromise().then(done, done.fail); - }); - - it('tsconfig target changes optimizations to use ES2015', (done) => { - host.replaceInFile('tsconfig.json', '"target": "es5"', '"target": "es2015"'); - - const overrides = { optimization: true }; - - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 2).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const fileName = join(outputPath, 'vendor.js'); - const content = virtualFs.fileBufferToString(host.scopedSync().read(fileName)); - expect(content).toMatch(/class \w{constructor\(\){/); - }), - ).toPromise().then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/output-hashing_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/output-hashing_spec_large.ts deleted file mode 100644 index 99ac52e72860..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/output-hashing_spec_large.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * @license - * 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 - */ - -import { DefaultTimeout, runTargetSpec } from '@angular-devkit/architect/testing'; -import { normalize } from '@angular-devkit/core'; -import { concatMap, tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; -import { lazyModuleFiles, lazyModuleImport } from './lazy-module_spec_large'; - - -describe('Browser Builder output hashing', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('updates hash as content changes', (done) => { - const OUTPUT_RE = /(main|styles|lazy\.module)\.([a-z0-9]+)\.(chunk|bundle)\.(js|css)$/; - - function generateFileHashMap(): Map { - const hashes = new Map(); - - host.scopedSync().list(normalize('./dist')).forEach(name => { - const matches = name.match(OUTPUT_RE); - if (matches) { - const [, module, hash] = matches; - hashes.set(module, hash); - } - }); - - return hashes; - } - - function validateHashes( - oldHashes: Map, - newHashes: Map, - shouldChange: Array, - ): void { - newHashes.forEach((hash, module) => { - if (hash == oldHashes.get(module)) { - if (shouldChange.includes(module)) { - throw new Error( - `Module "${module}" did not change hash (${hash}), but was expected to.`); - } - } else if (!shouldChange.includes(module)) { - throw new Error(`Module "${module}" changed hash (${hash}), but was not expected to.`); - } - }); - } - - let oldHashes: Map; - let newHashes: Map; - - host.writeMultipleFiles(lazyModuleFiles); - host.writeMultipleFiles(lazyModuleImport); - - const overrides = { outputHashing: 'all', extractCss: true }; - - // We must do several builds instead of a single one in watch mode, so that the output - // path is deleted on each run and only contains the most recent files. - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 3).pipe( - tap(() => { - // Save the current hashes. - oldHashes = generateFileHashMap(); - host.writeMultipleFiles(lazyModuleFiles); - host.writeMultipleFiles(lazyModuleImport); - }), - // Lazy chunk hash should have changed without modifying main bundle. - concatMap(() => runTargetSpec(host, browserTargetSpec, overrides)), - tap(() => { - newHashes = generateFileHashMap(); - validateHashes(oldHashes, newHashes, []); - oldHashes = newHashes; - host.writeMultipleFiles({ 'src/styles.css': 'body { background: blue; }' }); - }), - // Style hash should change. - concatMap(() => runTargetSpec(host, browserTargetSpec, overrides)), - tap(() => { - newHashes = generateFileHashMap(); - validateHashes(oldHashes, newHashes, ['styles']); - oldHashes = newHashes; - host.writeMultipleFiles({ 'src/app/app.component.css': 'h1 { margin: 10px; }' }); - }), - // Main hash should change, since inline styles go in the main bundle. - concatMap(() => runTargetSpec(host, browserTargetSpec, overrides)), - tap(() => { - newHashes = generateFileHashMap(); - validateHashes(oldHashes, newHashes, ['main']); - oldHashes = newHashes; - host.appendToFile('src/app/lazy/lazy.module.ts', `console.log(1);`); - }), - // Lazy loaded bundle should change, and so should inline. - concatMap(() => runTargetSpec(host, browserTargetSpec, overrides)), - tap(() => { - newHashes = generateFileHashMap(); - validateHashes(oldHashes, newHashes, ['lazy.module']); - oldHashes = newHashes; - host.appendToFile('src/main.ts', ''); - }), - // Nothing should have changed. - concatMap(() => runTargetSpec(host, browserTargetSpec, overrides)), - tap(() => { - newHashes = generateFileHashMap(); - validateHashes(oldHashes, newHashes, []); - }), - ).toPromise().then(done, done.fail); - }); - - it('supports options', (done) => { - host.writeMultipleFiles({ 'src/styles.css': `h1 { background: url('./spectrum.png')}` }); - host.writeMultipleFiles(lazyModuleFiles); - host.writeMultipleFiles(lazyModuleImport); - - const overrides = { outputHashing: 'all', extractCss: true }; - - // We must do several builds instead of a single one in watch mode, so that the output - // path is deleted on each run and only contains the most recent files. - // 'all' should hash everything. - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 2).pipe( - tap(() => { - expect(host.fileMatchExists('dist', /runtime\.[0-9a-f]{20}\.js/)).toBeTruthy(); - expect(host.fileMatchExists('dist', /main\.[0-9a-f]{20}\.js/)).toBeTruthy(); - expect(host.fileMatchExists('dist', /polyfills\.[0-9a-f]{20}\.js/)).toBeTruthy(); - expect(host.fileMatchExists('dist', /vendor\.[0-9a-f]{20}\.js/)).toBeTruthy(); - expect(host.fileMatchExists('dist', /styles\.[0-9a-f]{20}\.css/)).toBeTruthy(); - expect(host.fileMatchExists('dist', /spectrum\.[0-9a-f]{20}\.png/)).toBeTruthy(); - }), - // 'none' should hash nothing. - concatMap(() => runTargetSpec(host, browserTargetSpec, - { outputHashing: 'none', extractCss: true })), - tap(() => { - expect(host.fileMatchExists('dist', /runtime\.[0-9a-f]{20}\.js/)).toBeFalsy(); - expect(host.fileMatchExists('dist', /main\.[0-9a-f]{20}\.js/)).toBeFalsy(); - expect(host.fileMatchExists('dist', /polyfills\.[0-9a-f]{20}\.js/)).toBeFalsy(); - expect(host.fileMatchExists('dist', /vendor\.[0-9a-f]{20}\.js/)).toBeFalsy(); - expect(host.fileMatchExists('dist', /styles\.[0-9a-f]{20}\.css/)).toBeFalsy(); - expect(host.fileMatchExists('dist', /spectrum\.[0-9a-f]{20}\.png/)).toBeFalsy(); - }), - // 'media' should hash css resources only. - concatMap(() => runTargetSpec(host, browserTargetSpec, - { outputHashing: 'media', extractCss: true })), - tap(() => { - expect(host.fileMatchExists('dist', /runtime\.[0-9a-f]{20}\.js/)).toBeFalsy(); - expect(host.fileMatchExists('dist', /main\.[0-9a-f]{20}\.js/)).toBeFalsy(); - expect(host.fileMatchExists('dist', /polyfills\.[0-9a-f]{20}\.js/)).toBeFalsy(); - expect(host.fileMatchExists('dist', /vendor\.[0-9a-f]{20}\.js/)).toBeFalsy(); - expect(host.fileMatchExists('dist', /styles\.[0-9a-f]{20}\.css/)).toBeFalsy(); - expect(host.fileMatchExists('dist', /spectrum\.[0-9a-f]{20}\.png/)).toBeTruthy(); - }), - // 'bundles' should hash bundles only. - concatMap(() => runTargetSpec(host, browserTargetSpec, - { outputHashing: 'bundles', extractCss: true })), - tap(() => { - expect(host.fileMatchExists('dist', /runtime\.[0-9a-f]{20}\.js/)).toBeTruthy(); - expect(host.fileMatchExists('dist', /main\.[0-9a-f]{20}\.js/)).toBeTruthy(); - expect(host.fileMatchExists('dist', /polyfills\.[0-9a-f]{20}\.js/)).toBeTruthy(); - expect(host.fileMatchExists('dist', /vendor\.[0-9a-f]{20}\.js/)).toBeTruthy(); - expect(host.fileMatchExists('dist', /styles\.[0-9a-f]{20}\.css/)).toBeTruthy(); - expect(host.fileMatchExists('dist', /spectrum\.[0-9a-f]{20}\.png/)).toBeFalsy(); - }), - ).toPromise().then(done, done.fail); - }); - - it('does not hash lazy styles', (done) => { - const overrides = { - outputHashing: 'all', - extractCss: true, - styles: [{ input: 'src/styles.css', lazy: true }], - }; - - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout).pipe( - tap(() => { - expect(host.fileMatchExists('dist', /styles\.[0-9a-f]{20}\.js/)).toBeFalsy(); - expect(host.fileMatchExists('dist', /styles\.[0-9a-f]{20}\.js.map/)).toBeFalsy(); - expect(host.scopedSync().exists(normalize('dist/styles.css'))).toBe(true); - expect(host.scopedSync().exists(normalize('dist/styles.css.map'))).toBe(true); - }), - ).toPromise().then(done, done.fail); - }); - - it('does not hash lazy styles when optimization is enabled', (done) => { - const overrides = { - outputHashing: 'all', - extractCss: true, - optimization: true, - styles: [{ input: 'src/styles.css', lazy: true }], - }; - - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout).pipe( - tap(() => { - expect(host.fileMatchExists('dist', /styles\.[0-9a-f]{20}\.js/)).toBeFalsy(); - expect(host.fileMatchExists('dist', /styles\.[0-9a-f]{20}\.js.map/)).toBeFalsy(); - expect(host.scopedSync().exists(normalize('dist/styles.css'))).toBe(true); - expect(host.scopedSync().exists(normalize('dist/styles.css.map'))).toBe(true); - }), - ).toPromise().then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/output-path_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/output-path_spec_large.ts deleted file mode 100644 index 61fff7201105..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/output-path_spec_large.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { join, normalize, virtualFs } from '@angular-devkit/core'; -import { tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; - - -describe('Browser Builder output path', () => { - const outputPath = normalize('dist'); - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('deletes output path', (done) => { - // Write a file to the output path to later verify it was deleted. - host.scopedSync().write(join(outputPath, 'file.txt'), virtualFs.stringToFileBuffer('file')); - // Delete an app file to force a failed compilation. - // Failed compilations still delete files, but don't output any. - host.delete(join(host.root(), 'src', 'app', 'app.component.ts')).subscribe({ - error: done.fail, - }); - - runTargetSpec(host, browserTargetSpec).pipe( - tap((buildEvent) => { - expect(buildEvent.success).toBe(false); - expect(host.scopedSync().exists(outputPath)).toBe(false); - }), - ).toPromise().then(done, done.fail); - }); - - it('does not allow output path to be project root', (done) => { - const overrides = { outputPath: './' }; - - runTargetSpec(host, browserTargetSpec, overrides) - .subscribe(undefined, () => done(), done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/poll_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/poll_spec_large.ts deleted file mode 100644 index ea2b42387d58..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/poll_spec_large.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { debounceTime, take, tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; - - -describe('Browser Builder poll', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - const overrides = { watch: true, poll: 10000 }; - const intervals: number[] = []; - let startTime: number | undefined; - runTargetSpec(host, browserTargetSpec, overrides).pipe( - // Debounce 1s, otherwise changes are too close together and polling doesn't work. - debounceTime(1000), - tap((buildEvent) => { - expect(buildEvent.success).toBe(true); - if (startTime != undefined) { - intervals.push(Date.now() - startTime - 1000); - } - startTime = Date.now(); - host.appendToFile('src/main.ts', 'console.log(1);'); - }), - take(4), - ).subscribe(undefined, done.fail, () => { - intervals.sort(); - const median = intervals[Math.trunc(intervals.length / 2)]; - expect(median).toBeGreaterThan(3000); - expect(median).toBeLessThan(12000); - done(); - }); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/profile_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/profile_spec_large.ts deleted file mode 100644 index e17349953549..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/profile_spec_large.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { normalize } from '@angular-devkit/core'; -import { tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; - - -describe('Browser Builder profile', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - const overrides = { profile: true }; - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - expect(host.scopedSync().exists(normalize('chrome-profiler-events.json'))).toBe(true); - expect(host.scopedSync().exists(normalize('speed-measure-plugin.json'))).toBe(true); - }), - ).toPromise().then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/rebuild_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/rebuild_spec_large.ts deleted file mode 100644 index 9d9523965cda..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/rebuild_spec_large.ts +++ /dev/null @@ -1,368 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable:no-big-function - -import { DefaultTimeout, TestLogger, runTargetSpec } from '@angular-devkit/architect/testing'; -import { join, normalize, virtualFs } from '@angular-devkit/core'; -import { debounceTime, take, takeWhile, tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; -import { lazyModuleFiles, lazyModuleImport } from './lazy-module_spec_large'; - - -describe('Browser Builder rebuilds', () => { - const outputPath = normalize('dist'); - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - - it('rebuilds on TS file changes', (done) => { - const goldenValueFiles: { [path: string]: string } = { - 'src/app/app.module.ts': ` - import { BrowserModule } from '@angular/platform-browser'; - import { NgModule } from '@angular/core'; - - import { AppComponent } from './app.component'; - - @NgModule({ - declarations: [ - AppComponent - ], - imports: [ - BrowserModule - ], - providers: [], - bootstrap: [AppComponent] - }) - export class AppModule { } - - console.log('$$_E2E_GOLDEN_VALUE_1'); - export let X = '$$_E2E_GOLDEN_VALUE_2'; - `, - 'src/main.ts': ` - import { enableProdMode } from '@angular/core'; - import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; - - import { AppModule } from './app/app.module'; - import { environment } from './environments/environment'; - - if (environment.production) { - enableProdMode(); - } - - platformBrowserDynamic().bootstrapModule(AppModule); - - import * as m from './app/app.module'; - console.log(m.X); - console.log('$$_E2E_GOLDEN_VALUE_3'); - `, - }; - - const overrides = { watch: true }; - - let buildCount = 0; - let phase = 1; - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 3).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true, 'build should succeed')), - tap(() => { - buildCount++; - const hasLazyChunk = host.scopedSync().exists(join(outputPath, 'lazy-lazy-module.js')); - switch (phase) { - case 1: - // No lazy chunk should exist. - if (!hasLazyChunk) { - phase = 2; - host.writeMultipleFiles({ ...lazyModuleFiles, ...lazyModuleImport }); - } - break; - - case 2: - // A lazy chunk should have been with the filename. - if (hasLazyChunk) { - phase = 3; - host.writeMultipleFiles(goldenValueFiles); - } - break; - - case 3: - // The golden values should be present and in the right order. - const re = new RegExp( - /\$\$_E2E_GOLDEN_VALUE_1(.|\n|\r)*/.source - + /\$\$_E2E_GOLDEN_VALUE_2(.|\n|\r)*/.source - + /\$\$_E2E_GOLDEN_VALUE_3/.source, - ); - const fileName = './dist/main.js'; - const content = virtualFs.fileBufferToString( - host.scopedSync().read(normalize(fileName)), - ); - - if (re.test(content)) { - phase = 4; - } - break; - } - }), - takeWhile(() => phase < 4), - ).toPromise().then( - () => done(), - () => done.fail(`stuck at phase ${phase} [builds: ${buildCount}]`), - ); - }); - - it('rebuilds on CSS changes', (done) => { - const overrides = { watch: true }; - - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 3).pipe( - debounceTime(500), - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => host.appendToFile('src/app/app.component.css', ':host { color: blue; }')), - take(2), - ).toPromise().then(done, done.fail); - }); - - it('type checks on rebuilds', (done) => { - host.writeMultipleFiles({ - 'src/funky2.ts': `export const funky2 = (value: string) => value + 'hello';`, - 'src/funky.ts': `export * from './funky2';`, - }); - host.appendToFile('src/main.ts', ` - import { funky2 } from './funky'; - console.log(funky2('town')); - `); - - const overrides = { watch: true, forkTypeChecker: false }; - const logger = new TestLogger('rebuild-type-errors'); - const typeError = `is not assignable to parameter of type 'number'`; - let buildNumber = 0; - - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 3, logger).pipe( - debounceTime(1000), - tap((buildEvent) => { - buildNumber += 1; - switch (buildNumber) { - case 1: - expect(buildEvent.success).toBe(true); - // Make an invalid version of the file. - // Should trigger a rebuild, this time an error is expected. - host.writeMultipleFiles({ - 'src/funky2.ts': `export const funky2 = (value: number) => value + 1;`, - }); - break; - - case 2: - // The second build should error out with a type error on the type of an argument. - expect(buildEvent.success).toBe(false); - expect(logger.includes(typeError)).toBe(true); - logger.clear(); - // Change an UNRELATED file and the error should still happen. - // Should trigger a rebuild, this time an error is also expected. - host.appendToFile('src/app/app.module.ts', `console.log(1);`); - break; - - case 3: - // The third build should have the same error as the first. - expect(buildEvent.success).toBe(false); - expect(logger.includes(typeError)).toBe(true); - logger.clear(); - // Fix the error. - host.writeMultipleFiles({ - 'src/funky2.ts': `export const funky2 = (value: string) => value + 'hello';`, - }); - break; - - default: - expect(buildEvent.success).toBe(true); - break; - } - }), - take(4), - ).toPromise().then(done, done.fail); - }); - - it('rebuilds on type changes', (done) => { - host.writeMultipleFiles({ 'src/type.ts': `export type MyType = number;` }); - host.appendToFile('src/main.ts', `import { MyType } from './type';`); - - const overrides = { watch: true }; - - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 3).pipe( - debounceTime(1000), - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => host.writeMultipleFiles({ 'src/type.ts': `export type MyType = string;` })), - take(2), - ).toPromise().then(done, done.fail); - }); - - it('rebuilds shows error', (done) => { - host.replaceInFile('./src/app/app.component.ts', 'AppComponent', 'AppComponentZ'); - - const overrides = { watch: true, aot: false }; - let buildCount = 1; - const logger = new TestLogger('rebuild-errors'); - - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 3, logger).pipe( - tap((buildEvent) => { - switch (buildCount) { - case 1: - expect(buildEvent.success).toBe(false); - expect(logger.includes('AppComponent cannot be used as an entry component')).toBe(true); - logger.clear(); - - host.replaceInFile('./src/app/app.component.ts', 'AppComponentZ', 'AppComponent'); - break; - - default: - expect(buildEvent.success).toBe(true); - break; - } - buildCount ++; - }), - take(2), - ).toPromise().then(done, done.fail); - }); - - it('rebuilds after errors in AOT', (done) => { - // Save the original contents of `./src/app/app.component.ts`. - const origContent = virtualFs.fileBufferToString( - host.scopedSync().read(normalize('src/app/app.component.ts'))); - // Add a major static analysis error on a non-main file to the initial build. - host.replaceInFile('./src/app/app.component.ts', `'app-root'`, `(() => 'app-root')()`); - - const overrides = { watch: true, aot: true, forkTypeChecker: false }; - const logger = new TestLogger('rebuild-aot-errors'); - const staticAnalysisError = 'Function expressions are not supported in decorators'; - const syntaxError = 'Declaration or statement expected.'; - let buildNumber = 0; - - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 3, logger).pipe( - debounceTime(1000), - tap((buildEvent) => { - buildNumber += 1; - switch (buildNumber) { - case 1: - // The first build should error out with a static analysis error. - expect(buildEvent.success).toBe(false); - expect(logger.includes(staticAnalysisError)).toBe(true); - logger.clear(); - // Fix the static analysis error. - host.writeMultipleFiles({ 'src/app/app.component.ts': origContent }); - break; - - case 2: - expect(buildEvent.success).toBe(true); - // Add an syntax error to a non-main file. - host.appendToFile('src/app/app.component.ts', `]]]`); - break; - - case 3: - // The third build should have TS syntax error. - expect(buildEvent.success).toBe(false); - expect(logger.includes(syntaxError)).toBe(true); - logger.clear(); - // Fix the syntax error, but add the static analysis error again. - host.writeMultipleFiles({ - 'src/app/app.component.ts': origContent.replace(`'app-root'`, `(() => 'app-root')()`), - }); - break; - - case 4: - expect(buildEvent.success).toBe(false); - // Restore the file to a error-less state. - host.writeMultipleFiles({ 'src/app/app.component.ts': origContent }); - break; - - case 5: - // The fifth build should have everything fixed.. - expect(buildEvent.success).toBe(true); - expect(logger.includes(staticAnalysisError)).toBe(true); - break; - } - }), - take(5), - ).toPromise().then(done, done.fail); - }); - - - it('rebuilds AOT factories', (done) => { - - host.writeMultipleFiles({ - 'src/app/app.component.css': ` - @import './imported-styles.css'; - body {background-color: #00f;} - `, - 'src/app/imported-styles.css': 'p {color: #f00;}', - }); - - const overrides = { watch: true, aot: true, forkTypeChecker: false }; - let buildNumber = 0; - - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 3).pipe( - debounceTime(1000), - tap((buildEvent) => { - buildNumber += 1; - const fileName = './dist/main.js'; - let content; - switch (buildNumber) { - case 1: - // Trigger a few rebuilds first. - // The AOT compiler is still optimizing rebuilds on the first rebuilds. - expect(buildEvent.success).toBe(true); - host.appendToFile('src/main.ts', 'console.log(1);'); - break; - - case 2: - expect(buildEvent.success).toBe(true); - host.appendToFile('src/main.ts', 'console.log(1);'); - break; - - case 3: - // Change the component html. - expect(buildEvent.success).toBe(true); - host.appendToFile('src/app/app.component.html', '

HTML_REBUILD_STRING

'); - break; - - case 4: - // Check if html changes are added to factories. - expect(buildEvent.success).toBe(true); - content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - expect(content).toContain('HTML_REBUILD_STRING'); - // Change the component css. - host.appendToFile('src/app/app.component.css', 'CSS_REBUILD_STRING {color: #f00;}'); - break; - - case 5: - // Check if css changes are added to factories. - expect(buildEvent.success).toBe(true); - content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - expect(content).toContain('CSS_REBUILD_STRING'); - // Change the component css import. - host.appendToFile('src/app/app.component.css', 'CSS_DEP_REBUILD_STRING {color: #f00;}'); - break; - - case 6: - // Check if css import changes are added to factories. - expect(buildEvent.success).toBe(true); - content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - expect(content).toContain('CSS_DEP_REBUILD_STRING'); - // Change the component itself. - host.replaceInFile('src/app/app.component.ts', 'app-root', - 'app-root-FACTORY_REBUILD_STRING'); - break; - - case 7: - // Check if component changes are added to factories. - expect(buildEvent.success).toBe(true); - content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - expect(content).toContain('FACTORY_REBUILD_STRING'); - break; - } - }), - take(7), - ).toPromise().then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/replacements_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/replacements_spec_large.ts deleted file mode 100644 index e1fda961ae9b..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/replacements_spec_large.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * @license - * 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 - */ - -import { DefaultTimeout, TestLogger, runTargetSpec } from '@angular-devkit/architect/testing'; -import { join, normalize, virtualFs } from '@angular-devkit/core'; -import { of, race } from 'rxjs'; -import { delay, filter, map, take, takeUntil, takeWhile, tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; - - -describe('Browser Builder file replacements', () => { - const outputPath = normalize('dist'); - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - beforeEach(() => host.writeMultipleFiles({ - 'src/meaning-too.ts': 'export var meaning = 42;', - 'src/meaning.ts': `export var meaning = 10;`, - - 'src/main.ts': ` - import { meaning } from './meaning'; - - console.log(meaning); - `, - })); - - it('allows file replacements', (done) => { - const overrides = { - fileReplacements: [ - { - replace: normalize('/src/meaning.ts'), - with: normalize('/src/meaning-too.ts'), - }, - ], - }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const fileName = join(outputPath, 'main.js'); - expect(virtualFs.fileBufferToString(host.scopedSync().read(fileName))) - .toMatch(/meaning\s*=\s*42/); - expect(virtualFs.fileBufferToString(host.scopedSync().read(fileName))) - .not.toMatch(/meaning\s*=\s*10/); - }), - ).toPromise().then(done, done.fail); - }); - - it(`allows file replacements with deprecated format`, (done) => { - const overrides = { - fileReplacements: [ - { - src: normalize('/src/meaning.ts'), - replaceWith: normalize('/src/meaning-too.ts'), - }, - ], - }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const fileName = join(outputPath, 'main.js'); - expect(virtualFs.fileBufferToString(host.scopedSync().read(fileName))) - .toMatch(/meaning\s*=\s*42/); - expect(virtualFs.fileBufferToString(host.scopedSync().read(fileName))) - .not.toMatch(/meaning\s*=\s*10/); - }), - ).toPromise().then(done, done.fail); - }); - - it(`fails compilation with missing 'replace' file`, (done) => { - const overrides = { - fileReplacements: [ - { - replace: normalize('/src/meaning.ts'), - with: normalize('/src/meaning-three.ts'), - }, - ], - }; - - runTargetSpec(host, browserTargetSpec, overrides) - .subscribe(undefined, () => done(), done.fail); - }); - - it(`fails compilation with missing 'with' file`, (done) => { - const overrides = { - fileReplacements: [ - { - replace: normalize('/src/meaning-three.ts'), - with: normalize('/src/meaning-too.ts'), - }, - ], - }; - - runTargetSpec(host, browserTargetSpec, overrides) - .subscribe(undefined, () => done(), done.fail); - }); - - it('file replacements work with watch mode', (done) => { - const overrides = { - fileReplacements: [ - { - replace: normalize('/src/meaning.ts'), - with: normalize('/src/meaning-too.ts'), - }, - ], - watch: true, - }; - - let buildCount = 0; - let phase = 1; - runTargetSpec(host, browserTargetSpec, overrides, 30000).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true, 'build should succeed')), - tap(() => { - const fileName = join(outputPath, 'main.js'); - const content = virtualFs.fileBufferToString(host.scopedSync().read(fileName)); - const has42 = /meaning\s*=\s*42/.test(content); - buildCount++; - switch (phase) { - case 1: - const has10 = /meaning\s*=\s*10/.test(content); - - if (has42 && !has10) { - phase = 2; - host.writeMultipleFiles({ - 'src/meaning-too.ts': 'export var meaning = 84;', - }); - } - break; - - case 2: - const has84 = /meaning\s*=\s*84/.test(content); - - if (has84 && !has42) { - phase = 3; - } else { - // try triggering a rebuild again - host.writeMultipleFiles({ - 'src/meaning-too.ts': 'export var meaning = 84;', - }); - } - break; - } - }), - takeWhile(() => phase < 3), - ).toPromise().then( - () => done(), - () => done.fail(`stuck at phase ${phase} [builds: ${buildCount}]`), - ); - }); - - it('file replacements work with forked type checker on watch mode', async () => { - host.writeMultipleFiles({ - 'src/file-replaced.ts': 'export var obj = { one: 1, two: 2 };', - 'src/file.ts': `export var obj = { one: 1 };`, - 'src/main.ts': ` - import { obj } from './file'; - console.log(obj.two); - `, - }); - - const overrides = { - fileReplacements: [{ - replace: normalize('/src/file.ts'), - with: normalize('/src/file-replaced.ts'), - }], - watch: true, - }; - - const unexpectedError = `Property 'two' does not exist on type '{ one: number; }'`; - const expectedError = `Property 'prop' does not exist on type '{}'`; - const logger = new TestLogger('rebuild-type-errors'); - - // Race between a timeout and the expected log entry. - const stop$ = race( - of(null).pipe(delay(DefaultTimeout * 2 / 3)), - logger.pipe( - filter(entry => entry.message.includes(expectedError)), - map(entry => entry.message), - take(1), - ), - ); - - let errorAdded = false; - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout, logger).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true, 'build should succeed')), - tap(() => { - // Introduce a known type error to detect in the logger filter. - if (!errorAdded) { - host.appendToFile('src/main.ts', 'console.log({}.prop);'); - errorAdded = true; - } - }), - takeUntil(stop$), - ).subscribe(); - - const res = await stop$.toPromise(); - expect(res).not.toBe(null, 'Test timed out.'); - expect(res).not.toContain(unexpectedError); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/scripts-array_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/scripts-array_spec_large.ts deleted file mode 100644 index 40ab2b5fa8d0..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/scripts-array_spec_large.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * @license - * 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 - */ - -import { DefaultTimeout, runTargetSpec } from '@angular-devkit/architect/testing'; -import { PathFragment, join, normalize, virtualFs } from '@angular-devkit/core'; -import { tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; - - -describe('Browser Builder scripts array', () => { - - const outputPath = normalize('dist'); - const scripts: { [path: string]: string } = { - 'src/input-script.js': 'console.log(\'input-script\'); var number = 1+1;', - 'src/zinput-script.js': 'console.log(\'zinput-script\');', - 'src/finput-script.js': 'console.log(\'finput-script\');', - 'src/uinput-script.js': 'console.log(\'uinput-script\');', - 'src/binput-script.js': 'console.log(\'binput-script\');', - 'src/ainput-script.js': 'console.log(\'ainput-script\');', - 'src/cinput-script.js': 'console.log(\'cinput-script\');', - 'src/lazy-script.js': 'console.log(\'lazy-script\');', - 'src/pre-rename-script.js': 'console.log(\'pre-rename-script\');', - 'src/pre-rename-lazy-script.js': 'console.log(\'pre-rename-lazy-script\');', - }; - const getScriptsOption = () => [ - 'src/input-script.js', - 'src/zinput-script.js', - 'src/finput-script.js', - 'src/uinput-script.js', - 'src/binput-script.js', - 'src/ainput-script.js', - 'src/cinput-script.js', - { input: 'src/lazy-script.js', bundleName: 'lazy-script', lazy: true }, - { input: 'src/pre-rename-script.js', bundleName: 'renamed-script' }, - { input: 'src/pre-rename-lazy-script.js', bundleName: 'renamed-lazy-script', lazy: true }, - ]; - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - const matches: { [path: string]: string } = { - './dist/scripts.js': 'input-script', - './dist/lazy-script.js': 'lazy-script', - './dist/renamed-script.js': 'pre-rename-script', - './dist/renamed-lazy-script.js': 'pre-rename-lazy-script', - './dist/main.js': 'input-script', - './dist/index.html': '' - + '' - + '' - + '' - + '' - + '', - }; - - host.writeMultipleFiles(scripts); - host.appendToFile('src/main.ts', '\nimport \'./input-script.js\';'); - - // Remove styles so we don't have to account for them in the index.html order check. - const overrides = { - styles: [], - scripts: getScriptsOption(), - }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => Object.keys(matches).forEach(fileName => { - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - expect(content).toMatch(matches[fileName]); - })), - ).toPromise().then(done, done.fail); - }); - - it('uglifies, uses sourcemaps, and adds hashes', (done) => { - host.writeMultipleFiles(scripts); - - const overrides = { - optimization: true, - sourceMap: true, - outputHashing: 'all', - scripts: getScriptsOption(), - }; - - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 2).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const scriptsBundle = host.fileMatchExists(outputPath, /scripts\.[0-9a-f]{20}\.js/); - expect(scriptsBundle).toBeTruthy(); - const fileName = join(outputPath, scriptsBundle as PathFragment); - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - expect(content).toMatch('var number=2;'); - expect(host.fileMatchExists(outputPath, /scripts\.[0-9a-f]{20}\.js\.map/)) - .toBeTruthy(); - expect(host.fileMatchExists(outputPath, /renamed-script\.[0-9a-f]{20}\.js/)) - .toBeTruthy(); - expect(host.fileMatchExists(outputPath, /renamed-script\.[0-9a-f]{20}\.js\.map/)) - .toBeTruthy(); - expect(host.fileMatchExists(outputPath, /scripts\.[0-9a-f]{20}\.js/)).toBeTruthy(); - expect(host.scopedSync().exists(normalize('dist/lazy-script.js'))).toBe(true); - expect(host.scopedSync().exists(normalize('dist/lazy-script.js.map'))).toBe(true); - expect(host.scopedSync().exists(normalize('dist/renamed-lazy-script.js'))).toBe(true); - expect(host.scopedSync().exists(normalize('dist/renamed-lazy-script.js.map'))) - .toBe(true); - }), - ).toPromise().then(done, done.fail); - }); - - it('preserves script order', (done) => { - host.writeMultipleFiles(scripts); - - const overrides = { scripts: getScriptsOption() }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const re = new RegExp( - /.*['"]input-script['"](.|\n|\r)*/.source - + /['"]zinput-script['"](.|\n|\r)*/.source - + /['"]finput-script['"](.|\n|\r)*/.source - + /['"]uinput-script['"](.|\n|\r)*/.source - + /['"]binput-script['"](.|\n|\r)*/.source - + /['"]ainput-script['"](.|\n|\r)*/.source - + /['"]cinput-script['"]/.source, - ); - const fileName = './dist/scripts.js'; - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - expect(content).toMatch(re); - }), - ).toPromise().then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/service-worker_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/service-worker_spec_large.ts deleted file mode 100644 index c84150f23cfb..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/service-worker_spec_large.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { normalize, virtualFs } from '@angular-devkit/core'; -import { tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; - - -describe('Browser Builder service worker', () => { - const manifest = { - index: '/index.html', - assetGroups: [ - { - name: 'app', - installMode: 'prefetch', - resources: { - files: ['/favicon.ico', '/index.html'], - versionedFiles: [ - '/*.bundle.css', - '/*.bundle.js', - '/*.chunk.js', - ], - }, - }, - { - name: 'assets', - installMode: 'lazy', - updateMode: 'prefetch', - resources: { - files: ['/assets/**'], - }, - }, - ], - }; - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('errors if no ngsw-config.json is present', (done) => { - const overrides = { serviceWorker: true }; - - runTargetSpec(host, browserTargetSpec, overrides) - .subscribe(event => { - expect(event.success).toBe(false); - }, () => done(), done.fail); - }); - - it('works with service worker', (done) => { - host.writeMultipleFiles({ - 'src/ngsw-config.json': JSON.stringify(manifest), - 'src/assets/folder-asset.txt': 'folder-asset.txt', - }); - - const overrides = { serviceWorker: true }; - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap(buildEvent => { - expect(buildEvent.success).toBe(true); - expect(host.scopedSync().exists(normalize('dist/ngsw.json'))); - const ngswJson = JSON.parse(virtualFs.fileBufferToString( - host.scopedSync().read(normalize('dist/ngsw.json')))); - // Verify index and assets are there. - expect(ngswJson).toEqual({ - configVersion: 1, - index: '/index.html', - navigationUrls: [ - { positive: true, regex: '^\\\/.*$' }, - { positive: false, regex: '^\\\/(?:.+\\\/)?[^\/]*\\.[^\/]*$' }, - { positive: false, regex: '^\\\/(?:.+\\\/)?[^\/]*__[^\/]*$' }, - { positive: false, regex: '^\\\/(?:.+\\\/)?[^\/]*__[^\/]*\\\/.*$' }, - ], - assetGroups: [ - { - name: 'app', - installMode: 'prefetch', - updateMode: 'prefetch', - urls: [ - '/favicon.ico', - '/index.html', - ], - patterns: [], - }, - { - name: 'assets', - installMode: 'lazy', - updateMode: 'prefetch', - urls: [ - '/assets/folder-asset.txt', - ], - patterns: [], - }, - ], - dataGroups: [], - hashTable: { - '/favicon.ico': '84161b857f5c547e3699ddfbffc6d8d737542e01', - '/assets/folder-asset.txt': '617f202968a6a81050aa617c2e28e1dca11ce8d4', - '/index.html': '843c96f0aeadc8f093b1b2203c08891ecd8f7425', - }, - }); - }), - ).toPromise().then(done, done.fail); - }); - - it('works with service worker and baseHref', (done) => { - host.writeMultipleFiles({ - 'src/ngsw-config.json': JSON.stringify(manifest), - 'src/assets/folder-asset.txt': 'folder-asset.txt', - }); - - const overrides = { serviceWorker: true, baseHref: '/foo/bar' }; - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap(buildEvent => { - expect(buildEvent.success).toBe(true); - expect(host.scopedSync().exists(normalize('dist/ngsw.json'))); - const ngswJson = JSON.parse(virtualFs.fileBufferToString( - host.scopedSync().read(normalize('dist/ngsw.json')))); - // Verify index and assets include the base href. - expect(ngswJson).toEqual({ - configVersion: 1, - index: '/foo/bar/index.html', - navigationUrls: [ - { positive: true, regex: '^\\\/.*$' }, - { positive: false, regex: '^\\\/(?:.+\\\/)?[^\/]*\\.[^\/]*$' }, - { positive: false, regex: '^\\\/(?:.+\\\/)?[^\/]*__[^\/]*$' }, - { positive: false, regex: '^\\\/(?:.+\\\/)?[^\/]*__[^\/]*\\\/.*$' }, - ], - assetGroups: [ - { - name: 'app', - installMode: 'prefetch', - updateMode: 'prefetch', - urls: [ - '/foo/bar/favicon.ico', - '/foo/bar/index.html', - ], - patterns: [], - }, - { - name: 'assets', - installMode: 'lazy', - updateMode: 'prefetch', - urls: [ - '/foo/bar/assets/folder-asset.txt', - ], - patterns: [], - }, - ], - dataGroups: [], - hashTable: { - '/foo/bar/favicon.ico': '84161b857f5c547e3699ddfbffc6d8d737542e01', - '/foo/bar/assets/folder-asset.txt': '617f202968a6a81050aa617c2e28e1dca11ce8d4', - '/foo/bar/index.html': '9ef50361678004b3b197c12cbc74962e5a15b844', - }, - }); - }), - ).toPromise().then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/source-map_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/source-map_spec_large.ts deleted file mode 100644 index 4708376f1f7f..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/source-map_spec_large.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { join, normalize, virtualFs } from '@angular-devkit/core'; -import { tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; - - -describe('Browser Builder source map', () => { - const outputPath = normalize('dist'); - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - const overrides = { sourceMap: true }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const fileName = join(outputPath, 'main.js.map'); - expect(host.scopedSync().exists(fileName)).toBe(true); - }), - ).toPromise().then(done, done.fail); - }); - - it('does not output source map when disabled', (done) => { - const overrides = { sourceMap: false }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const fileName = join(outputPath, 'main.js.map'); - expect(host.scopedSync().exists(fileName)).toBe(false); - }), - ).toPromise().then(done, done.fail); - }); - - it('supports eval source map', (done) => { - const overrides = { sourceMap: true, evalSourceMap: true }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - expect(host.scopedSync().exists(join(outputPath, 'main.js.map'))).toBe(false); - const fileName = join(outputPath, 'main.js'); - const content = virtualFs.fileBufferToString(host.scopedSync().read(fileName)); - expect(content).toContain('eval("function webpackEmptyAsyncContext'); - }), - ).toPromise().then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/stats-json_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/stats-json_spec_large.ts deleted file mode 100644 index c34556ad0fb0..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/stats-json_spec_large.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { join, normalize } from '@angular-devkit/core'; -import { tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; - - -describe('Browser Builder stats json', () => { - const outputPath = normalize('dist'); - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - const overrides = { statsJson: true }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const fileName = join(outputPath, 'stats.json'); - expect(host.scopedSync().exists(fileName)).toBe(true); - }), - ).toPromise().then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/styles_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/styles_spec_large.ts deleted file mode 100644 index d22d251e84fa..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/styles_spec_large.ts +++ /dev/null @@ -1,527 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable:no-big-function - -import { DefaultTimeout, runTargetSpec } from '@angular-devkit/architect/testing'; -import { normalize, tags, virtualFs } from '@angular-devkit/core'; -import { concatMap, tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; - - -describe('Browser Builder styles', () => { - const extensionsWithImportSupport = ['css', 'scss', 'less', 'styl']; - const extensionsWithVariableSupport = ['scss', 'less', 'styl']; - const imgSvg = ` - - - - `; - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('supports global styles', (done) => { - const styles: { [path: string]: string } = { - 'src/string-style.css': '.string-style { color: red }', - 'src/input-style.css': '.input-style { color: red }', - 'src/lazy-style.css': '.lazy-style { color: red }', - 'src/pre-rename-style.css': '.pre-rename-style { color: red }', - 'src/pre-rename-lazy-style.css': '.pre-rename-lazy-style { color: red }', - }; - const getStylesOption = () => [ - 'src/input-style.css', - { input: 'src/lazy-style.css', bundleName: 'lazy-style', lazy: true }, - { input: 'src/pre-rename-style.css', bundleName: 'renamed-style' }, - { input: 'src/pre-rename-lazy-style.css', bundleName: 'renamed-lazy-style', lazy: true }, - ]; - const cssMatches: { [path: string]: string } = { - './dist/styles.css': '.input-style', - './dist/lazy-style.css': '.lazy-style', - './dist/renamed-style.css': '.pre-rename-style', - './dist/renamed-lazy-style.css': '.pre-rename-lazy-style', - }; - const cssIndexMatches: { [path: string]: string } = { - './dist/index.html': '' - + '', - }; - const jsMatches: { [path: string]: string } = { - './dist/styles.js': '.input-style', - './dist/lazy-style.js': '.lazy-style', - './dist/renamed-style.js': '.pre-rename-style', - './dist/renamed-lazy-style.js': '.pre-rename-lazy-style', - }; - const jsIndexMatches: { [path: string]: string } = { - './dist/index.html': '' - + '' - + '' - + '' - + '' - + '', - }; - - host.writeMultipleFiles(styles); - - const overrides = { extractCss: true, styles: getStylesOption() }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - // Check css files were created. - tap(() => Object.keys(cssMatches).forEach(fileName => { - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - expect(content).toMatch(cssMatches[fileName]); - })), - // Check no js files are created. - tap(() => Object.keys(jsMatches).forEach(key => - expect(host.scopedSync().exists(normalize(key))).toBe(false), - )), - // Check check index has styles in the right order. - tap(() => Object.keys(cssIndexMatches).forEach(fileName => { - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - expect(content).toMatch(cssIndexMatches[fileName]); - })), - // Also test with extractCss false. - concatMap(() => runTargetSpec(host, browserTargetSpec, - { extractCss: false, styles: getStylesOption() })), - // TODO: figure out why adding this tap breaks typings. - // This also happens in the output-hashing spec. - // tap((buildEvent) => expect(buildEvent.success).toBe(true)), - // Check js files were created. - tap(() => Object.keys(jsMatches).forEach(fileName => { - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - expect(content).toMatch(jsMatches[fileName]); - })), - // Check no css files are created. - tap(() => Object.keys(cssMatches).forEach(key => - expect(host.scopedSync().exists(normalize(key))).toBe(false), - )), - // Check check index has styles in the right order. - tap(() => Object.keys(jsIndexMatches).forEach(fileName => { - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - expect(content).toMatch(jsIndexMatches[fileName]); - })), - ).toPromise().then(done, done.fail); - }); - - it('supports empty styleUrls in components', (done) => { - host.writeMultipleFiles({ - './src/app/app.component.ts': ` - import { Component } from '@angular/core'; - - @Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: [] - }) - export class AppComponent { - title = 'app'; - } - `, - }); - - const overrides = { extractCss: true }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }); - - extensionsWithImportSupport.forEach(ext => { - it(`supports imports in ${ext} files`, (done) => { - host.writeMultipleFiles({ - [`src/styles.${ext}`]: ` - @import './imported-styles.${ext}'; - body { background-color: #00f; } - `, - [`src/imported-styles.${ext}`]: 'p { background-color: #f00; }', - [`src/app/app.component.${ext}`]: ` - @import './imported-component-styles.${ext}'; - .outer { - .inner { - background: #fff; - } - } - `, - [`src/app/imported-component-styles.${ext}`]: 'h1 { background: #000; }', - }); - - const matches: { [path: string]: RegExp } = { - 'dist/styles.css': new RegExp( - // The global style should be there - /p\s*{\s*background-color: #f00;\s*}(.|\n|\r)*/.source - // The global style via import should be there - + /body\s*{\s*background-color: #00f;\s*}/.source, - ), - 'dist/styles.css.map': /"mappings":".+"/, - 'dist/main.js': new RegExp( - // The component style should be there - /h1(.|\n|\r)*background:\s*#000(.|\n|\r)*/.source - // The component style via import should be there - + /.outer(.|\n|\r)*.inner(.|\n|\r)*background:\s*#[fF]+/.source, - ), - }; - - const overrides = { - extractCss: true, - sourceMap: true, - styles: [`src/styles.${ext}`], - }; - - host.replaceInFile('src/app/app.component.ts', './app.component.css', - `./app.component.${ext}`); - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => Object.keys(matches).forEach(fileName => { - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - expect(content).toMatch(matches[fileName]); - })), - ).toPromise().then(done, done.fail); - }); - }); - - extensionsWithImportSupport.forEach(ext => { - it(`supports material imports in ${ext} files`, (done) => { - host.writeMultipleFiles({ - [`src/styles.${ext}`]: ` - @import "~@angular/material/prebuilt-themes/indigo-pink.css"; - @import "@angular/material/prebuilt-themes/indigo-pink.css"; - `, - [`src/app/app.component.${ext}`]: ` - @import "~@angular/material/prebuilt-themes/indigo-pink.css"; - @import "@angular/material/prebuilt-themes/indigo-pink.css"; - `, - }); - host.replaceInFile('src/app/app.component.ts', './app.component.css', - `./app.component.${ext}`); - - const overrides = { - extractCss: true, - styles: [{ input: `src/styles.${ext}` }], - }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }); - }); - - it(`supports material icons`, (done) => { - const overrides = { - extractCss: true, - optimization: true, - styles: [ - { input: '../../../../node_modules/material-design-icons/iconfont/material-icons.css' }, - ], - }; - - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 2).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }); - - extensionsWithVariableSupport.forEach(ext => { - it(`supports ${ext} includePaths`, (done) => { - - let variableAssignment = ''; - let variablereference = ''; - if (ext === 'scss') { - variableAssignment = '$primary-color:'; - variablereference = '$primary-color'; - } else if (ext === 'styl') { - variableAssignment = '$primary-color ='; - variablereference = '$primary-color'; - } else if (ext === 'less') { - variableAssignment = '@primary-color:'; - variablereference = '@primary-color'; - } - - host.writeMultipleFiles({ - [`src/style-paths/variables.${ext}`]: `${variableAssignment} #f00;`, - [`src/styles.${ext}`]: ` - @import 'variables'; - h1 { color: ${variablereference}; } - `, - [`src/app/app.component.${ext}`]: ` - @import 'variables'; - h2 { color: ${variablereference}; } - `, - }); - - const matches: { [path: string]: RegExp } = { - 'dist/styles.css': /h1\s*{\s*color: #f00;\s*}/, - 'dist/main.js': /h2.*{.*color: #f00;.*}/, - }; - - host.replaceInFile('src/app/app.component.ts', './app.component.css', - `./app.component.${ext}`); - - const overrides = { - extractCss: true, - styles: [`src/styles.${ext}`], - stylePreprocessorOptions: { - includePaths: ['src/style-paths'], - }, - }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => Object.keys(matches).forEach(fileName => { - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - expect(content).toMatch(matches[fileName]); - })), - ).toPromise().then(done, done.fail); - }); - }); - - it(`supports font-awesome imports`, (done) => { - host.writeMultipleFiles({ - 'src/styles.scss': ` - $fa-font-path: "~font-awesome/fonts"; - @import "~font-awesome/scss/font-awesome"; - `, - }); - - const overrides = { extractCss: true, styles: [`src/styles.scss`] }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }, 30000); - - it(`supports font-awesome imports without extractCss`, (done) => { - host.writeMultipleFiles({ - 'src/styles.scss': ` - @import "~font-awesome/css/font-awesome.css"; - `, - }); - - const overrides = { extractCss: false, styles: [`src/styles.scss`] }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }, 30000); - - it(`uses autoprefixer`, (done) => { - host.writeMultipleFiles({ - 'src/styles.css': tags.stripIndents` - /* normal-comment */ - /*! important-comment */ - div { flex: 1 }`, - }); - - const overrides = { extractCss: true, optimization: false }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const fileName = 'dist/styles.css'; - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - expect(content).toContain(tags.stripIndents` - /* normal-comment */ - /*! important-comment */ - div { -ms-flex: 1; flex: 1 }`); - }), - ).toPromise().then(done, done.fail); - }); - - it(`minimizes css`, (done) => { - host.writeMultipleFiles({ - 'src/styles.css': tags.stripIndents` - /* normal-comment */ - /*! important-comment */ - div { flex: 1 }`, - }); - - const overrides = { extractCss: true, optimization: true }; - - runTargetSpec(host, browserTargetSpec, overrides, DefaultTimeout * 2).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const fileName = 'dist/styles.css'; - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - expect(content).toContain( - '/*! important-comment */div{-ms-flex:1;flex:1}'); - }), - ).toPromise().then(done, done.fail); - }); - - // TODO: consider making this a unit test in the url processing plugins. - it(`supports baseHref and deployUrl in resource urls`, (done) => { - // Use a large image for the relative ref so it cannot be inlined. - host.copyFile('src/spectrum.png', './src/assets/global-img-relative.png'); - host.copyFile('src/spectrum.png', './src/assets/component-img-relative.png'); - host.writeMultipleFiles({ - 'src/styles.css': ` - h1 { background: url('/assets/global-img-absolute.svg'); } - h2 { background: url('./assets/global-img-relative.png'); } - `, - 'src/app/app.component.css': ` - h3 { background: url('/assets/component-img-absolute.svg'); } - h4 { background: url('../assets/component-img-relative.png'); } - `, - 'src/assets/global-img-absolute.svg': imgSvg, - 'src/assets/component-img-absolute.svg': imgSvg, - }); - - const stylesBundle = 'dist/styles.css'; - const mainBundle = 'dist/main.js'; - - // Check base paths are correctly generated. - runTargetSpec(host, browserTargetSpec, { aot: true, extractCss: true }).pipe( - tap(() => { - const styles = virtualFs.fileBufferToString( - host.scopedSync().read(normalize(stylesBundle)), - ); - const main = virtualFs.fileBufferToString(host.scopedSync().read(normalize(mainBundle))); - expect(styles).toContain(`url('/assets/global-img-absolute.svg')`); - expect(styles).toContain(`url('global-img-relative.png')`); - expect(main).toContain(`url('/assets/component-img-absolute.svg')`); - expect(main).toContain(`url('component-img-relative.png')`); - expect(host.scopedSync().exists(normalize('dist/assets/global-img-absolute.svg'))) - .toBe(true); - expect(host.scopedSync().exists(normalize('dist/global-img-relative.png'))) - .toBe(true); - expect(host.scopedSync().exists(normalize('dist/assets/component-img-absolute.svg'))) - .toBe(true); - expect(host.scopedSync().exists(normalize('dist/component-img-relative.png'))) - .toBe(true); - }), - // Check urls with deploy-url scheme are used as is. - concatMap(() => runTargetSpec(host, browserTargetSpec, - { extractCss: true, baseHref: '/base/', deployUrl: 'http://deploy.url/' }, - )), - tap(() => { - const styles = virtualFs.fileBufferToString( - host.scopedSync().read(normalize(stylesBundle)), - ); - const main = virtualFs.fileBufferToString(host.scopedSync().read(normalize(mainBundle))); - expect(styles) - .toContain(`url('http://deploy.url/assets/global-img-absolute.svg')`); - expect(main) - .toContain(`url('http://deploy.url/assets/component-img-absolute.svg')`); - }), - // Check urls with base-href scheme are used as is (with deploy-url). - concatMap(() => runTargetSpec(host, browserTargetSpec, - { extractCss: true, baseHref: 'http://base.url/', deployUrl: 'deploy/' }, - )), - tap(() => { - const styles = virtualFs.fileBufferToString( - host.scopedSync().read(normalize(stylesBundle)), - ); - const main = virtualFs.fileBufferToString(host.scopedSync().read(normalize(mainBundle))); - expect(styles) - .toContain(`url('http://base.url/deploy/assets/global-img-absolute.svg')`); - expect(main) - .toContain(`url('http://base.url/deploy/assets/component-img-absolute.svg')`); - }), - // Check urls with deploy-url and base-href scheme only use deploy-url. - concatMap(() => runTargetSpec(host, browserTargetSpec, { - extractCss: true, - baseHref: 'http://base.url/', - deployUrl: 'http://deploy.url/', - }, - )), - tap(() => { - const styles = virtualFs.fileBufferToString( - host.scopedSync().read(normalize(stylesBundle)), - ); - const main = virtualFs.fileBufferToString(host.scopedSync().read(normalize(mainBundle))); - expect(styles).toContain(`url('http://deploy.url/assets/global-img-absolute.svg')`); - expect(main).toContain(`url('http://deploy.url/assets/component-img-absolute.svg')`); - }), - // Check with schemeless base-href and deploy-url flags. - concatMap(() => runTargetSpec(host, browserTargetSpec, - { extractCss: true, baseHref: '/base/', deployUrl: 'deploy/' }, - )), - tap(() => { - const styles = virtualFs.fileBufferToString( - host.scopedSync().read(normalize(stylesBundle)), - ); - const main = virtualFs.fileBufferToString(host.scopedSync().read(normalize(mainBundle))); - expect(styles).toContain(`url('/base/deploy/assets/global-img-absolute.svg')`); - expect(main).toContain(`url('/base/deploy/assets/component-img-absolute.svg')`); - }), - // Check with identical base-href and deploy-url flags. - concatMap(() => runTargetSpec(host, browserTargetSpec, - { extractCss: true, baseHref: '/base/', deployUrl: '/base/' }, - )), - tap(() => { - const styles = virtualFs.fileBufferToString( - host.scopedSync().read(normalize(stylesBundle)), - ); - const main = virtualFs.fileBufferToString(host.scopedSync().read(normalize(mainBundle))); - expect(styles).toContain(`url('/base/assets/global-img-absolute.svg')`); - expect(main).toContain(`url('/base/assets/component-img-absolute.svg')`); - }), - // Check with only base-href flag. - concatMap(() => runTargetSpec(host, browserTargetSpec, - { extractCss: true, baseHref: '/base/' }, - )), - tap(() => { - const styles = virtualFs.fileBufferToString( - host.scopedSync().read(normalize(stylesBundle)), - ); - const main = virtualFs.fileBufferToString(host.scopedSync().read(normalize(mainBundle))); - expect(styles).toContain(`url('/base/assets/global-img-absolute.svg')`); - expect(main).toContain(`url('/base/assets/component-img-absolute.svg')`); - }), - ).toPromise().then(done, done.fail); - }, 90000); - - it(`supports bootstrap@4`, (done) => { - const overrides = { - extractCss: true, - styles: ['../../../../node_modules/bootstrap/dist/css/bootstrap.css'], - scripts: ['../../../../node_modules/bootstrap/dist/js/bootstrap.js'], - }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }); - - it(`supports inline javascript in less`, (done) => { - const overrides = { styles: [`src/styles.less`] }; - host.writeMultipleFiles({ - 'src/styles.less': ` - .myFunction() { - @functions: ~\`(function () { - return ''; - })()\`; - } - .myFunction(); - `, - }); - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }); - - it('supports Protocol-relative Url', (done) => { - host.writeMultipleFiles({ - 'src/styles.css': ` - body { - background-image: url('//cdn.com/classic-bg.jpg'); - } - `, - }); - - const overrides = { extractCss: true, optimization: true }; - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => { - expect(buildEvent.success).toBe(true); - - const filePath = './dist/styles.css'; - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(filePath))); - expect(content).toContain('background-image:url(//cdn.com/classic-bg.jpg)'); - }), - ).toPromise().then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/subresource-integrity_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/subresource-integrity_spec_large.ts deleted file mode 100644 index 83c31d5870ff..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/subresource-integrity_spec_large.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { join, normalize, virtualFs } from '@angular-devkit/core'; -import { tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; - - -describe('Browser Builder subresource integrity', () => { - const outputPath = normalize('dist'); - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - host.writeMultipleFiles({ - 'src/my-js-file.js': `console.log(1); export const a = 2;`, - 'src/main.ts': `import { a } from './my-js-file'; console.log(a);`, - }); - - const overrides = { subresourceIntegrity: true }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const fileName = join(outputPath, 'index.html'); - const content = virtualFs.fileBufferToString(host.scopedSync().read(fileName)); - expect(content).toMatch(/integrity="\w+-[A-Za-z0-9\/\+=]+"/); - }), - ).toPromise().then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/tsconfig-paths_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/tsconfig-paths_spec_large.ts deleted file mode 100644 index a37a5d387745..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/tsconfig-paths_spec_large.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; - - -describe('Browser Builder tsconfig paths', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - host.replaceInFile('src/app/app.module.ts', './app.component', '@root/app/app.component'); - host.replaceInFile('tsconfig.json', /"baseUrl": ".\/",/, ` - "baseUrl": "./", - "paths": { - "@root/*": [ - "./src/*" - ] - }, - `); - - runTargetSpec(host, browserTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }); - - it('works', (done) => { - host.writeMultipleFiles({ - 'src/meaning-too.ts': 'export var meaning = 42;', - 'src/app/shared/meaning.ts': 'export var meaning = 42;', - 'src/app/shared/index.ts': `export * from './meaning'`, - }); - host.replaceInFile('tsconfig.json', /"baseUrl": ".\/",/, ` - "baseUrl": "./", - "paths": { - "@shared": [ - "src/app/shared" - ], - "@shared/*": [ - "src/app/shared/*" - ], - "*": [ - "*", - "src/app/shared/*" - ] - }, - `); - host.appendToFile('src/app/app.component.ts', ` - import { meaning } from 'src/app/shared/meaning'; - import { meaning as meaning2 } from '@shared'; - import { meaning as meaning3 } from '@shared/meaning'; - import { meaning as meaning4 } from 'meaning'; - import { meaning as meaning5 } from 'src/meaning-too'; - - // need to use imports otherwise they are ignored and - // no error is outputted, even if baseUrl/paths don't work - console.log(meaning) - console.log(meaning2) - console.log(meaning3) - console.log(meaning4) - console.log(meaning5) - `); - - runTargetSpec(host, browserTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/vendor-chunk_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/vendor-chunk_spec_large.ts deleted file mode 100644 index 7c0fc24801f8..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/vendor-chunk_spec_large.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { join, normalize } from '@angular-devkit/core'; -import { tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; - - -describe('Browser Builder vendor chunk', () => { - const outputPath = normalize('dist'); - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - const overrides = { vendorChunk: true }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const fileName = join(outputPath, 'vendor.js'); - expect(host.scopedSync().exists(fileName)).toBe(true); - }), - ).toPromise().then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/browser/vendor-source-map_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/vendor-source-map_spec_large.ts deleted file mode 100644 index 45bc61914039..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/vendor-source-map_spec_large.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { join, normalize, virtualFs } from '@angular-devkit/core'; -import * as path from 'path'; -import { tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; - -describe('Browser Builder external source map', () => { - const outputPath = normalize('dist'); - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - const overrides = { sourceMap: true, vendorSourceMap: true }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const fileName = join(outputPath, 'vendor.js.map'); - expect(host.scopedSync().exists(fileName)).toBe(true); - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - // this is due the fact that some vendors like `tslib` sourcemaps to js files - const sourcePath = JSON.parse(content).sources[0]; - expect(path.extname(sourcePath)).toBe('.ts', `${sourcePath} extention should be '.ts'`); - }), - ).toPromise().then(done, done.fail); - }); - - it('does not map sourcemaps from external library when disabled', (done) => { - const overrides = { sourceMap: true, vendorSourceMap: false }; - - runTargetSpec(host, browserTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const fileName = join(outputPath, 'vendor.js.map'); - expect(host.scopedSync().exists(fileName)).toBe(true); - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - // this is due the fact that some vendors like `tslib` sourcemaps to js files - const sourcePath = JSON.parse(content).sources[0]; - expect(path.extname(sourcePath)).toBe('.js', `${sourcePath} extention should be '.js'`); - }), - ).toPromise().then(done, done.fail); - }); - -}); diff --git a/packages/angular_devkit/build_angular/test/browser/works_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/works_spec_large.ts deleted file mode 100644 index aeeefccf3f3d..000000000000 --- a/packages/angular_devkit/build_angular/test/browser/works_spec_large.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { join, normalize } from '@angular-devkit/core'; -import { tap } from 'rxjs/operators'; -import { browserTargetSpec, host } from '../utils'; - - -describe('Browser Builder basic test', () => { - const outputPath = normalize('dist'); - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - runTargetSpec(host, browserTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - // Default files should be in outputPath. - expect(host.scopedSync().exists(join(outputPath, 'runtime.js'))).toBe(true); - expect(host.scopedSync().exists(join(outputPath, 'main.js'))).toBe(true); - expect(host.scopedSync().exists(join(outputPath, 'polyfills.js'))).toBe(true); - expect(host.scopedSync().exists(join(outputPath, 'styles.js'))).toBe(true); - expect(host.scopedSync().exists(join(outputPath, 'vendor.js'))).toBe(true); - expect(host.scopedSync().exists(join(outputPath, 'favicon.ico'))).toBe(true); - expect(host.scopedSync().exists(join(outputPath, 'index.html'))).toBe(true); - }), - ).toPromise().then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/dev-server/deploy-url_spec_large.ts b/packages/angular_devkit/build_angular/test/dev-server/deploy-url_spec_large.ts deleted file mode 100644 index 7079aafdacdf..000000000000 --- a/packages/angular_devkit/build_angular/test/dev-server/deploy-url_spec_large.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @license - * 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 - */ - -import { request, runTargetSpec } from '@angular-devkit/architect/testing'; -import { from } from 'rxjs'; -import { concatMap, take, tap } from 'rxjs/operators'; -import { DevServerBuilderOptions } from '../../src'; -import { devServerTargetSpec, host } from '../utils'; - - -describe('Dev Server Deploy Url', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - const overrides: Partial = { deployUrl: 'test/' }; - - runTargetSpec(host, devServerTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - concatMap(() => from(request('http://localhost:4200/test/polyfills.js'))), - tap(response => expect(response).toContain('window["webpackJsonp"]')), - take(1), - ).toPromise().then(done, done.fail); - }, 30000); -}); diff --git a/packages/angular_devkit/build_angular/test/dev-server/proxy_spec_large.ts b/packages/angular_devkit/build_angular/test/dev-server/proxy_spec_large.ts deleted file mode 100644 index 729cddc370f1..000000000000 --- a/packages/angular_devkit/build_angular/test/dev-server/proxy_spec_large.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @license - * 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 - */ - -import { request, runTargetSpec } from '@angular-devkit/architect/testing'; -import * as express from 'express'; // tslint:disable-line:no-implicit-dependencies -import * as http from 'http'; -import { from } from 'rxjs'; -import { concatMap, take, tap } from 'rxjs/operators'; -import { DevServerBuilderOptions } from '../../src'; -import { devServerTargetSpec, host } from '../utils'; - - -describe('Dev Server Builder proxy', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - // Create an express app that serves as a proxy. - const app = express(); - const server = http.createServer(app); - server.listen(0); - - app.set('port', server.address().port); - app.get('/api/test', function (_req, res) { - res.send('TEST_API_RETURN'); - }); - - const backendHost = 'localhost'; - const backendPort = server.address().port; - const proxyServerUrl = `http://${backendHost}:${backendPort}`; - - host.writeMultipleFiles({ - 'proxy.config.json': `{ "/api/*": { "target": "${proxyServerUrl}" } }`, - }); - - const overrides: Partial = { proxyConfig: 'proxy.config.json' }; - - runTargetSpec(host, devServerTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - concatMap(() => from(request('http://localhost:4200/api/test'))), - tap(response => { - expect(response).toContain('TEST_API_RETURN'); - server.close(); - }), - take(1), - ).toPromise().then(done, done.fail); - }, 30000); - - it('errors out with a missing proxy file', (done) => { - const overrides: Partial = { proxyConfig: '../proxy.config.json' }; - - runTargetSpec(host, devServerTargetSpec, overrides) - .subscribe(undefined, () => done(), done.fail); - }, 30000); -}); diff --git a/packages/angular_devkit/build_angular/test/dev-server/public-host_spec_large.ts b/packages/angular_devkit/build_angular/test/dev-server/public-host_spec_large.ts deleted file mode 100644 index adba0f34b0fe..000000000000 --- a/packages/angular_devkit/build_angular/test/dev-server/public-host_spec_large.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @license - * 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 - */ - -import { request, runTargetSpec } from '@angular-devkit/architect/testing'; -import { from } from 'rxjs'; -import { concatMap, take, tap } from 'rxjs/operators'; -import { DevServerBuilderOptions } from '../../src'; -import { devServerTargetSpec, host } from '../utils'; - - -describe('Dev Server Builder public host', () => { - // We have to spoof the host to a non-numeric one because Webpack Dev Server does not - // check the hosts anymore when requests come from numeric IP addresses. - const headers = { host: 'http://spoofy.mcspoofface' }; - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - runTargetSpec(host, devServerTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - concatMap(() => from(request('http://localhost:4200/', headers))), - tap(response => expect(response).toContain('Invalid Host header')), - take(1), - ).toPromise().then(done, done.fail); - }, 30000); - - it('works', (done) => { - const overrides: Partial = { publicHost: headers.host }; - - runTargetSpec(host, devServerTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - concatMap(() => from(request('http://localhost:4200/', headers))), - tap(response => expect(response).toContain('HelloWorldApp')), - take(1), - ).toPromise().then(done, done.fail); - }, 30000); - - it('works', (done) => { - const overrides: Partial = { disableHostCheck: true }; - - runTargetSpec(host, devServerTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - concatMap(() => from(request('http://localhost:4200/', headers))), - tap(response => expect(response).toContain('HelloWorldApp')), - take(1), - ).toPromise().then(done, done.fail); - }, 30000); -}); diff --git a/packages/angular_devkit/build_angular/test/dev-server/serve-path_spec_large.ts b/packages/angular_devkit/build_angular/test/dev-server/serve-path_spec_large.ts deleted file mode 100644 index 5a09230bb4fe..000000000000 --- a/packages/angular_devkit/build_angular/test/dev-server/serve-path_spec_large.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @license - * 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 - */ - -import { request, runTargetSpec } from '@angular-devkit/architect/testing'; -import { from } from 'rxjs'; -import { concatMap, take, tap } from 'rxjs/operators'; -import { DevServerBuilderOptions } from '../../src'; -import { devServerTargetSpec, host } from '../utils'; - - -describe('Dev Server Builder serve path', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - const overrides: Partial = { servePath: 'test/' }; - - runTargetSpec(host, devServerTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - concatMap(() => from(request('http://localhost:4200/test/polyfills.js'))), - tap(response => expect(response).toContain('window["webpackJsonp"]')), - take(1), - ).toPromise().then(done, done.fail); - }, 30000); -}); diff --git a/packages/angular_devkit/build_angular/test/dev-server/ssl_spec_large.ts b/packages/angular_devkit/build_angular/test/dev-server/ssl_spec_large.ts deleted file mode 100644 index 1e876376f87e..000000000000 --- a/packages/angular_devkit/build_angular/test/dev-server/ssl_spec_large.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * @license - * 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 - */ - -import { request, runTargetSpec } from '@angular-devkit/architect/testing'; -import { tags } from '@angular-devkit/core'; -import { from } from 'rxjs'; -import { concatMap, take, tap } from 'rxjs/operators'; -import { DevServerBuilderOptions } from '../../src'; -import { devServerTargetSpec, host } from '../utils'; - - -describe('Dev Server Builder ssl', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - const overrides: Partial = { ssl: true }; - - runTargetSpec(host, devServerTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - concatMap(() => from(request('https://localhost:4200/index.html'))), - tap(response => expect(response).toContain('HelloWorldApp')), - take(1), - ).toPromise().then(done, done.fail); - }, 30000); - - it('supports key and cert', (done) => { - host.writeMultipleFiles({ - 'ssl/server.key': tags.stripIndents` - -----BEGIN RSA PRIVATE KEY----- - MIIEpAIBAAKCAQEA0+kOHdfpsPNUguCtai27DJ+DuOfVw4gw1P3BgYhNG8qoukaW - gdDLowj59+cXXgn9ZnQ8PdvZXhCEQcP3wjd1cBPuGQzO7BekoKEgzfGE/zjrrIeL - Q7tHYx9ddCPotQX4OJu4FZFJyS1Ar7zDDpQ9fSw6qmsXWN6I18fIGSVvbDbVB9rw - iAsLGe2jWadjXAjSqVOy6aT+CKJgnRnxudNZGP1LRC1YDRl/s7icCIh/9gEGfn7G - gwQQ9AM2p3JNjP2quclSjBvMv0uVj+yzL2bPGRz0hu90CDaIEU2FtBCse50/O75q - x0bQnPbuHKD3ajs0DfQYoAmFZGK078ZDl/VQxwIDAQABAoIBAEl17kXcNo/4GqDw - QE2hoslCdwhfnhQVn1AG09ESriBnRcylccF4308aaoVM4CXicqzUuJl9IEJimWav - B7GVRinfTtfyP71KiPCCSvv5sPBFDDYYGugVAS9UjTIYzLAMbLs7CDq5zglmnZkO - Z9QjAZnl/kRbsZFGO8wJ3s0Q1Cp/ygZcvFU331K2jHXW7B4YXiFOH/lBQrjdz0Gy - WBjX4zIdNWnwarvxu46IS/0z1P1YOHM8+B1Uv54MG94A6szBdd/Vp0cQRs78t/Cu - BQ1Rnuk16Pi+ieC5K04yUgeuNusYW0PWLtPX1nKNp9z46bmD1NHKAxaoDFXr7qP3 - pZCaDMkCgYEA8mmTYrhXJTRIrOxoUwM1e3OZ0uOxVXJJ8HF6X8t+UO6dFxXB/JC9 - ZBc+94cZQapaKFOeMmd/j3L2CQIjChk5yKV/G3Io+raxIoAAKPCkMF4NQQVvvNkS - CAGl61Qa78DoF5Habumz0AC1R9P877kNTC0aPSt4lhPWgfotbZNNMlMCgYEA38nM - s4a0pZseXPkuOtPYX/3Ms3E+d70XKSFuIMCHCg79YGsQ8h/9apYcPyeYkpQ0a4gs - I3IUqMaXC2OyqWA5LU1BZv51mXb6zcb2pokZfpiSWk+7sy5XjkE9EmQxp3xHfV3c - EO/DxHfWNvtMjESMbhu0yVzM2O/Aa53Tl9lqAT0CgYEA1dXBuHyqCtyTG08zO78B - 55Ny5rAJ1zkI9jvz2hr0o0nJcvqzcyruliNXXRxkcCNoglg4nXfk81JSrGGhLSBR - c6hhdoF+mqKboLZO7c5Q14WvpWK5TVoiaMOja/J2DHYbhecYS2yGPH7TargaUBDq - JP9IPRtitOhs+Z0Jg7ZDi5cCgYAMb7B6gY/kbBxh2k8hYchyfS41AqQQD2gMFxmB - pHFcs7yM8SY97l0s4S6sq8ykyKupFiYtyhcv0elu7pltJDXJOLPbv2RVpPEHInlu - g8vw5xWrAydRK9Adza5RKVRBFHz8kIy8PDbK4kX7RDfay6xqKgv/7LJNk/VDhb/O - fnyPmQKBgQDg/o8Ubf/gxA9Husnuld4DBu3wwFhkMlWqyO9QH3cKgojQ2JGSrfDz - xHhetmhionEyzg0JCaMSpzgIHY+8o/NAwc++OjNHEoYp3XWM9GTp81ROMz6b83jV - biVR9N0MhONdwF6vtzDCcJxNIUe2p4lTvLf/Xd9jaQDNXe35Gxsdyg== - -----END RSA PRIVATE KEY----- - `, - 'ssl/server.crt': tags.stripIndents` - -----BEGIN CERTIFICATE----- - MIID5jCCAs6gAwIBAgIJAJOebwfGCm61MA0GCSqGSIb3DQEBBQUAMFUxCzAJBgNV - BAYTAlVTMRAwDgYDVQQIEwdHZW9yZ2lhMRAwDgYDVQQHEwdBdGxhbnRhMRAwDgYD - VQQKEwdBbmd1bGFyMRAwDgYDVQQLEwdBbmd1bGFyMB4XDTE2MTAwNDAxMDAyMVoX - DTI2MTAwMjAxMDAyMVowVTELMAkGA1UEBhMCVVMxEDAOBgNVBAgTB0dlb3JnaWEx - EDAOBgNVBAcTB0F0bGFudGExEDAOBgNVBAoTB0FuZ3VsYXIxEDAOBgNVBAsTB0Fu - Z3VsYXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDT6Q4d1+mw81SC - 4K1qLbsMn4O459XDiDDU/cGBiE0byqi6RpaB0MujCPn35xdeCf1mdDw929leEIRB - w/fCN3VwE+4ZDM7sF6SgoSDN8YT/OOush4tDu0djH110I+i1Bfg4m7gVkUnJLUCv - vMMOlD19LDqqaxdY3ojXx8gZJW9sNtUH2vCICwsZ7aNZp2NcCNKpU7LppP4IomCd - GfG501kY/UtELVgNGX+zuJwIiH/2AQZ+fsaDBBD0Azanck2M/aq5yVKMG8y/S5WP - 7LMvZs8ZHPSG73QINogRTYW0EKx7nT87vmrHRtCc9u4coPdqOzQN9BigCYVkYrTv - xkOX9VDHAgMBAAGjgbgwgbUwHQYDVR0OBBYEFG4VV6/aNLx/qFIS9MhAWuyeV5OX - MIGFBgNVHSMEfjB8gBRuFVev2jS8f6hSEvTIQFrsnleTl6FZpFcwVTELMAkGA1UE - BhMCVVMxEDAOBgNVBAgTB0dlb3JnaWExEDAOBgNVBAcTB0F0bGFudGExEDAOBgNV - BAoTB0FuZ3VsYXIxEDAOBgNVBAsTB0FuZ3VsYXKCCQCTnm8HxgputTAMBgNVHRME - BTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQDO4jZT/oKVxaiWr+jV5TD+qwThl9zT - Uw/ZpFDkdbZdY/baCFaLCiJwkK9+puMOabLvm1VzcnHHWCoiUNbWpw8AOumLEnTv - ze/5OZXJ6XlA9kd9f3hDlN5zNB3S+Z2nKIrkPGfxQZ603QCbWaptip5dxgek6oDZ - YXVtnbOnPznRsG5jh07U49RO8CNebqZLzdRToLgObbqYlfRMcbUxCOHXjnB5wUlp - 377Iivm4ldnCTvFOjEiDh+FByWL5xic7PjyJPZFMidiYTmsGilP9XTFC83CRZwz7 - vW+RCSlU6x8Uejz98BPmASoqCuCTUeOo+2pFelFhX9NwR/Sb6b7ybdPv - -----END CERTIFICATE----- - `, - }); - - const overrides: Partial = { - ssl: true, - sslKey: '../ssl/server.key', - sslCert: '../ssl/server.crt', - }; - - runTargetSpec(host, devServerTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - concatMap(() => from(request('https://localhost:4200/index.html'))), - tap(response => expect(response).toContain('HelloWorldApp')), - take(1), - ).toPromise().then(done, done.fail); - }, 30000); -}); diff --git a/packages/angular_devkit/build_angular/test/dev-server/works_spec_large.ts b/packages/angular_devkit/build_angular/test/dev-server/works_spec_large.ts deleted file mode 100644 index f6984ed88b3e..000000000000 --- a/packages/angular_devkit/build_angular/test/dev-server/works_spec_large.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @license - * 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 - */ - -import { TestLogger, request, runTargetSpec } from '@angular-devkit/architect/testing'; -import { from } from 'rxjs'; -import { concatMap, take, tap } from 'rxjs/operators'; -import { DevServerBuilderOptions } from '../../src'; -import { devServerTargetSpec, host } from '../utils'; - - -describe('Dev Server Builder', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - runTargetSpec(host, devServerTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - concatMap(() => from(request('http://localhost:4200/index.html'))), - tap(response => expect(response).toContain('HelloWorldApp')), - take(1), - ).toPromise().then(done, done.fail); - }, 30000); - - it('works with verbose', (done) => { - const overrides: Partial = { verbose: true }; - const logger = new TestLogger('verbose-serve'); - runTargetSpec(host, devServerTargetSpec, overrides, undefined, logger).pipe( - tap(() => expect(logger.includes('Built at')).toBe(true)), - take(1), - ).toPromise().then(done, done.fail); - }, 30000); - - it(`doesn't serve files on the cwd directly`, async () => { - const res = await runTargetSpec(host, devServerTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - // When webpack-dev-server doesn't have `contentBase: false`, this will serve the repo README. - concatMap(() => from(request('http://localhost:4200/README.md'))), - take(1), - ).toPromise(); - - expect(res).not.toContain('This file is automatically generated during release.'); - expect(res).toContain('HelloWorldApp'); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/extract-i18n/works_spec_large.ts b/packages/angular_devkit/build_angular/test/extract-i18n/works_spec_large.ts deleted file mode 100644 index 2c69b92dd220..000000000000 --- a/packages/angular_devkit/build_angular/test/extract-i18n/works_spec_large.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * @license - * 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 - */ - -import { DefaultTimeout, TestLogger, runTargetSpec } from '@angular-devkit/architect/testing'; -import { join, normalize, virtualFs } from '@angular-devkit/core'; -import { tap } from 'rxjs/operators'; -import { extractI18nTargetSpec, host } from '../utils'; - - -describe('Extract i18n Target', () => { - const extractionFile = join(normalize('src'), 'messages.xlf'); - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - host.appendToFile('src/app/app.component.html', '

i18n test

'); - - runTargetSpec(host, extractI18nTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - expect(host.scopedSync().exists((extractionFile))).toBe(true); - expect(virtualFs.fileBufferToString(host.scopedSync().read(extractionFile))) - .toMatch(/i18n test/); - }), - ).toPromise().then(done, done.fail); - }, 30000); - - it('shows errors', (done) => { - const logger = new TestLogger('i18n-errors'); - host.appendToFile('src/app/app.component.html', - '

Hello world inner

'); - - runTargetSpec(host, extractI18nTargetSpec, {}, DefaultTimeout, logger).pipe( - tap((buildEvent) => { - expect(buildEvent.success).toBe(false); - const msg = 'Could not mark an element as translatable inside a translatable section'; - expect(logger.includes(msg)).toBe(true); - }), - ).toPromise().then(done, done.fail); - }, 30000); - - it('supports locale', (done) => { - host.appendToFile('src/app/app.component.html', '

i18n test

'); - const overrides = { i18nLocale: 'fr' }; - - runTargetSpec(host, extractI18nTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - expect(host.scopedSync().exists((extractionFile))).toBe(true); - expect(virtualFs.fileBufferToString(host.scopedSync().read(extractionFile))) - .toContain('source-language="fr"'); - }), - ).toPromise().then(done, done.fail); - }, 30000); - - it('supports out file', (done) => { - host.appendToFile('src/app/app.component.html', '

i18n test

'); - const outFile = 'messages.fr.xlf'; - const extractionFile = join(normalize('src'), outFile); - const overrides = { outFile }; - - runTargetSpec(host, extractI18nTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - expect(host.scopedSync().exists(extractionFile)).toBe(true); - expect(virtualFs.fileBufferToString(host.scopedSync().read(extractionFile))) - .toMatch(/i18n test/); - }), - ).toPromise().then(done, done.fail); - }, 30000); - - it('supports output path', (done) => { - host.appendToFile('src/app/app.component.html', '

i18n test

'); - // Note: this folder will not be created automatically. It must exist beforehand. - const outputPath = 'app'; - const extractionFile = join(normalize('src'), outputPath, 'messages.xlf'); - const overrides = { outputPath }; - - runTargetSpec(host, extractI18nTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - expect(host.scopedSync().exists(extractionFile)).toBe(true); - expect(virtualFs.fileBufferToString(host.scopedSync().read(extractionFile))) - .toMatch(/i18n test/); - }), - ).toPromise().then(done, done.fail); - }, 30000); - - it('supports i18n format', (done) => { - host.appendToFile('src/app/app.component.html', '

i18n test

'); - const extractionFile = join(normalize('src'), 'messages.xmb'); - const overrides = { i18nFormat: 'xmb' }; - - runTargetSpec(host, extractI18nTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - expect(host.scopedSync().exists(extractionFile)).toBe(true); - expect(virtualFs.fileBufferToString(host.scopedSync().read(extractionFile))) - .toMatch(/i18n test/); - }), - ).toPromise().then(done, done.fail); - }, 30000); -}); diff --git a/packages/angular_devkit/build_angular/test/karma/assets_spec_large.ts b/packages/angular_devkit/build_angular/test/karma/assets_spec_large.ts deleted file mode 100644 index 60d003e5a152..000000000000 --- a/packages/angular_devkit/build_angular/test/karma/assets_spec_large.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { tap } from 'rxjs/operators'; -import { host, karmaTargetSpec } from '../utils'; - - -describe('Karma Builder assets', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - const assets: { [path: string]: string } = { - './src/string-file-asset.txt': 'string-file-asset.txt', - './src/string-folder-asset/file.txt': 'string-folder-asset.txt', - './src/glob-asset.txt': 'glob-asset.txt', - './src/folder/folder-asset.txt': 'folder-asset.txt', - './src/output-asset.txt': 'output-asset.txt', - }; - host.writeMultipleFiles(assets); - host.writeMultipleFiles({ - 'src/app/app.module.ts': ` - import { BrowserModule } from '@angular/platform-browser'; - import { NgModule } from '@angular/core'; - import { HttpModule } from '@angular/http'; - import { AppComponent } from './app.component'; - - @NgModule({ - declarations: [ - AppComponent - ], - imports: [ - BrowserModule, - HttpModule - ], - providers: [], - bootstrap: [AppComponent] - }) - export class AppModule { } - `, - 'src/app/app.component.ts': ` - import { Component } from '@angular/core'; - import { Http, Response } from '@angular/http'; - - @Component({ - selector: 'app-root', - template: '

{{asset.content }}

' - }) - export class AppComponent { - public assets = [ - { path: './string-file-asset.txt', content: '' }, - { path: './string-folder-asset/file.txt', content: '' }, - { path: './glob-asset.txt', content: '' }, - { path: './folder/folder-asset.txt', content: '' }, - { path: './output-folder/output-asset.txt', content: '' }, - ]; - constructor(private http: Http) { - this.assets.forEach(asset => http.get(asset.path) - .subscribe(res => asset.content = res['_body'])); - } - }`, - 'src/app/app.component.spec.ts': ` - import { TestBed, async } from '@angular/core/testing'; - import { HttpModule } from '@angular/http'; - import { AppComponent } from './app.component'; - - describe('AppComponent', () => { - beforeEach(async(() => { - TestBed.configureTestingModule({ - imports: [ - HttpModule - ], - declarations: [ - AppComponent - ] - }).compileComponents(); - })); - - it('should create the app', async(() => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.debugElement.componentInstance; - expect(app).toBeTruthy(); - })); - });`, - }); - - const overrides = { - assets: [ - 'src/string-file-asset.txt', - 'src/string-folder-asset', - { glob: 'glob-asset.txt', input: 'src/', output: '/' }, - { glob: 'output-asset.txt', input: 'src/', output: '/output-folder' }, - { glob: '**/*', input: 'src/folder', output: '/folder' }, - ], - }; - - runTargetSpec(host, karmaTargetSpec, overrides).pipe( - tap(buildEvent => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }, 45000); -}); diff --git a/packages/angular_devkit/build_angular/test/karma/code-coverage_spec_large.ts b/packages/angular_devkit/build_angular/test/karma/code-coverage_spec_large.ts deleted file mode 100644 index 8440e1143420..000000000000 --- a/packages/angular_devkit/build_angular/test/karma/code-coverage_spec_large.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { normalize, virtualFs } from '@angular-devkit/core'; -import { debounceTime, tap } from 'rxjs/operators'; -import { NormalizedKarmaBuilderSchema } from '../../src'; -import { host, karmaTargetSpec } from '../utils'; - - -describe('Karma Builder code coverage', () => { - const coverageFilePath = normalize('coverage/lcov.info'); - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - const overrides: Partial = { codeCoverage: true }; - - runTargetSpec(host, karmaTargetSpec, overrides).pipe( - // It seems like the coverage files take a while being written to disk, so we wait 500ms here. - debounceTime(500), - tap(buildEvent => { - expect(buildEvent.success).toBe(true); - expect(host.scopedSync().exists(coverageFilePath)).toBe(true); - const content = virtualFs.fileBufferToString(host.scopedSync().read(coverageFilePath)); - expect(content).toContain('polyfills.ts'); - expect(content).toContain('test.ts'); - }), - ).toPromise().then(done, done.fail); - }, 120000); - - it('supports exclude', (done) => { - const overrides: Partial = { - codeCoverage: true, - codeCoverageExclude: [ - 'src/polyfills.ts', - '**/test.ts', - ], - }; - - runTargetSpec(host, karmaTargetSpec, overrides).pipe( - // It seems like the coverage files take a while being written to disk, so we wait 500ms here. - debounceTime(500), - tap(buildEvent => { - expect(buildEvent.success).toBe(true); - expect(host.scopedSync().exists(coverageFilePath)).toBe(true); - const content = virtualFs.fileBufferToString(host.scopedSync().read(coverageFilePath)); - expect(content).not.toContain('polyfills.ts'); - expect(content).not.toContain('test.ts'); - }), - ).toPromise().then(done, done.fail); - }, 120000); - - it(`should collect coverage from paths in 'sourceRoot'`, (done) => { - const overrides: Partial = { codeCoverage: true }; - - const files: { [path: string]: string } = { - './dist/my-lib/index.d.ts': ` - export declare const title = 'app'; - `, - './dist/my-lib/index.js': ` - export const title = 'app'; - `, - './src/app/app.component.ts': ` - import { Component } from '@angular/core'; - import { title } from 'my-lib'; - - @Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.css'] - }) - export class AppComponent { - title = title; - } - `, - }; - - host.writeMultipleFiles(files); - - host.replaceInFile('tsconfig.json', /"baseUrl": ".\/",/, ` - "baseUrl": "./", - "paths": { - "my-lib": [ - "./dist/my-lib" - ] - }, - `); - - runTargetSpec(host, karmaTargetSpec, overrides).pipe( - // It seems like the coverage files take a while being written to disk, so we wait 500ms here. - debounceTime(500), - tap(buildEvent => { - expect(buildEvent.success).toBe(true); - expect(host.scopedSync().exists(coverageFilePath)).toBe(true); - const content = virtualFs.fileBufferToString(host.scopedSync().read(coverageFilePath)); - expect(content).not.toContain('my-lib'); - }), - ).toPromise().then(done, done.fail); - }, 120000); -}); diff --git a/packages/angular_devkit/build_angular/test/karma/rebuilds_spec_large.ts b/packages/angular_devkit/build_angular/test/karma/rebuilds_spec_large.ts deleted file mode 100644 index 494823bf121a..000000000000 --- a/packages/angular_devkit/build_angular/test/karma/rebuilds_spec_large.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { debounceTime, take, tap } from 'rxjs/operators'; -import { host, karmaTargetSpec } from '../utils'; - - -// Karma watch mode is currently bugged: -// - errors print a huge stack trace -// - karma does not have a way to close the server -// gracefully (https://github.com/karma-runner/karma/issues/3149) -// TODO: fix these before 6.0 final. -xdescribe('Karma Builder watch mode', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - const overrides = { watch: true }; - runTargetSpec(host, karmaTargetSpec, overrides).pipe( - debounceTime(500), - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - take(1), - ).toPromise().then(done, done.fail); - }, 30000); - - it('recovers from compilation failures in watch mode', (done) => { - const overrides = { watch: true }; - let buildNumber = 0; - - runTargetSpec(host, karmaTargetSpec, overrides).pipe( - debounceTime(500), - tap((buildEvent) => { - buildNumber += 1; - switch (buildNumber) { - case 1: - // Karma run should succeed. - // Add a compilation error. - expect(buildEvent.success).toBe(true); - host.writeMultipleFiles({ - 'src/app/app.component.spec.ts': '

definitely not typescript

', - }); - break; - - case 2: - // Karma run should fail due to compilation error. Fix it. - expect(buildEvent.success).toBe(false); - host.writeMultipleFiles({ 'src/foo.spec.ts': '' }); - break; - - case 3: - // Karma run should succeed again. - expect(buildEvent.success).toBe(true); - break; - - default: - break; - } - }), - take(3), - ).toPromise().then(done, done.fail); - }, 30000); - - it('does not rebuild when nothing changed', (done) => { - // Start the server in watch mode, wait for the first build to finish, touch - // test.js without changing it, wait 5s then exit unsuscribe, verify only one event was emitted. - }, 30000); -}); diff --git a/packages/angular_devkit/build_angular/test/karma/replacements_spec_large.ts b/packages/angular_devkit/build_angular/test/karma/replacements_spec_large.ts deleted file mode 100644 index 870961a92263..000000000000 --- a/packages/angular_devkit/build_angular/test/karma/replacements_spec_large.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { normalize } from '@angular-devkit/core'; -import { tap } from 'rxjs/operators'; -import { host, karmaTargetSpec } from '../utils'; - - -describe('Karma Builder file replacements', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('allows file replacements', (done) => { - host.writeMultipleFiles({ - 'src/meaning-too.ts': 'export var meaning = 42;', - 'src/meaning.ts': `export var meaning = 10;`, - - 'src/test.ts': ` - import { meaning } from './meaning'; - - describe('Test file replacement', () => { - it('should replace file', () => { - expect(meaning).toBe(42); - }); - }); - `, - }); - - const overrides = { - fileReplacements: [{ - replace: normalize('/src/meaning.ts'), - with: normalize('/src/meaning-too.ts'), - }], - }; - - runTargetSpec(host, karmaTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }, 30000); -}); diff --git a/packages/angular_devkit/build_angular/test/karma/styles_spec_large.ts b/packages/angular_devkit/build_angular/test/karma/styles_spec_large.ts deleted file mode 100644 index 899fbea31e53..000000000000 --- a/packages/angular_devkit/build_angular/test/karma/styles_spec_large.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { tap } from 'rxjs/operators'; -import { host, karmaTargetSpec } from '../utils'; - - -describe('Karma Builder global styles', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - host.writeMultipleFiles({ - 'src/styles.css': 'p {display: none}', - 'src/app/app.component.ts': ` - import { Component } from '@angular/core'; - - @Component({ - selector: 'app-root', - template: '

Hello World

' - }) - export class AppComponent { - } - `, - 'src/app/app.component.spec.ts': ` - import { TestBed, async } from '@angular/core/testing'; - import { HttpModule } from '@angular/http'; - import { AppComponent } from './app.component'; - - describe('AppComponent', () => { - beforeEach(async(() => { - TestBed.configureTestingModule({ - imports: [ - HttpModule - ], - declarations: [ - AppComponent - ] - }).compileComponents(); - })); - - it('should not contain text that is hidden via css', async(() => { - const fixture = TestBed.createComponent(AppComponent); - expect(fixture.nativeElement.innerText).not.toContain('Hello World'); - })); - });`, - }); - - runTargetSpec(host, karmaTargetSpec).pipe( - tap(buildEvent => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/karma/works_spec_large.ts b/packages/angular_devkit/build_angular/test/karma/works_spec_large.ts deleted file mode 100644 index d2f11a2c201d..000000000000 --- a/packages/angular_devkit/build_angular/test/karma/works_spec_large.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { tap } from 'rxjs/operators'; -import { host, karmaTargetSpec } from '../utils'; - - -// TODO: replace this with an "it()" macro that's reusable globally. -let linuxOnlyIt: typeof it = it; -if (process.platform.startsWith('win')) { - linuxOnlyIt = xit; -} - -describe('Karma Builder', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('runs', (done) => { - runTargetSpec(host, karmaTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }, 30000); - - // This test seems to succeed on appveyor but not terminate Karma, leaving the port used - // and killing Chrome after 60s. This causes other tests that use Chrome to fail. - linuxOnlyIt('fails with broken compilation', (done) => { - host.writeMultipleFiles({ - 'src/app/app.component.spec.ts': '

definitely not typescript

', - }); - runTargetSpec(host, karmaTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(false)), - ).toPromise().then(done, done.fail); - }, 30000); - - it('supports ES2015 target', (done) => { - host.replaceInFile('tsconfig.json', '"target": "es5"', '"target": "es2015"'); - runTargetSpec(host, karmaTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }, 30000); -}); diff --git a/packages/angular_devkit/build_angular/test/protractor/works_spec_large.ts b/packages/angular_devkit/build_angular/test/protractor/works_spec_large.ts deleted file mode 100644 index 0970512d951b..000000000000 --- a/packages/angular_devkit/build_angular/test/protractor/works_spec_large.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { normalize } from '@angular-devkit/core'; -import { retry } from 'rxjs/operators'; -import { host, protractorTargetSpec } from '../utils'; - - -describe('Protractor Builder', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - runTargetSpec(host, protractorTargetSpec).pipe( - retry(3), - ).toPromise().then(done, done.fail); - }, 30000); - - it('works with no devServerTarget', (done) => { - const overrides = { devServerTarget: undefined }; - - runTargetSpec(host, protractorTargetSpec, overrides).pipe( - // This should fail because no server is available for connection. - ).subscribe(undefined, () => done(), done.fail); - }, 30000); - - it('overrides protractor specs', (done) => { - host.scopedSync().rename(normalize('./e2e/app.e2e-spec.ts'), - normalize('./e2e/renamed-app.e2e.spec.ts')); - - const overrides = { specs: ['./e2e/renamed-app.e2e.spec.ts'] }; - - runTargetSpec(host, protractorTargetSpec, overrides).pipe( - retry(3), - ).toPromise().then(done, done.fail); - }, 60000); - - it('overrides protractor suites', (done) => { - host.scopedSync().rename(normalize('./e2e/app.e2e-spec.ts'), - normalize('./e2e/renamed-app.e2e-spec.ts')); - - // Suites block need to be added in the protractor.conf.js file to test suites - host.replaceInFile('protractor.conf.js', `allScriptsTimeout: 11000,`, ` - allScriptsTimeout: 11000, - suites: { - app: './e2e/app.e2e-spec.ts' - }, - `); - - const overrides = { suite: 'app' }; - - runTargetSpec(host, protractorTargetSpec, overrides).pipe( - retry(3), - ).toPromise().then(done, done.fail); - }, 60000); - - // TODO: test `element-explorer` when the protractor builder emits build events with text. - // .then(() => execAndWaitForOutputToMatch('ng', ['e2e', '--element-explorer'], - // /Element Explorer/)) - // .then(() => killAllProcesses(), (err: any) => { - // killAllProcesses(); - // throw err; - // }) -}); diff --git a/packages/angular_devkit/build_angular/test/server/base_spec_large.ts b/packages/angular_devkit/build_angular/test/server/base_spec_large.ts deleted file mode 100644 index 845666bf5e05..000000000000 --- a/packages/angular_devkit/build_angular/test/server/base_spec_large.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { join, normalize, virtualFs } from '@angular-devkit/core'; -import { take, tap } from 'rxjs/operators'; -import { host } from '../utils'; - - -describe('Server Builder', () => { - const outputPath = normalize('dist-server'); - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works (base)', (done) => { - const overrides = { }; - - runTargetSpec(host, { project: 'app', target: 'server' }, overrides).pipe( - tap((buildEvent) => { - expect(buildEvent.success).toBe(true); - - const fileName = join(outputPath, 'main.js'); - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - expect(content).toMatch(/AppServerModuleNgFactory/); - }), - ).toPromise().then(done, done.fail); - }); - - it('supports sourcemaps', (done) => { - const overrides = { sourceMap: true }; - - runTargetSpec(host, { project: 'app', target: 'server' }, overrides).pipe( - tap((buildEvent) => { - expect(buildEvent.success).toBe(true); - - const fileName = join(outputPath, 'main.js'); - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - expect(content).toMatch(/AppServerModuleNgFactory/); - expect(host.scopedSync().exists(join(outputPath, 'main.js.map'))).toBeTruthy(); - }), - ).toPromise().then(done, done.fail); - }); - - it('runs watch mode', (done) => { - const overrides = { watch: true }; - - runTargetSpec(host, { project: 'app', target: 'server' }, overrides).pipe( - tap((buildEvent) => { - expect(buildEvent.success).toBe(true); - - const fileName = join(outputPath, 'main.js'); - const content = virtualFs.fileBufferToString(host.scopedSync().read(normalize(fileName))); - expect(content).toMatch(/AppServerModuleNgFactory/); - }), - take(1), - ).subscribe(undefined, done.fail, done); - }); -}); diff --git a/packages/angular_devkit/build_angular/test/tslint/works_spec_large.ts b/packages/angular_devkit/build_angular/test/tslint/works_spec_large.ts deleted file mode 100644 index 69192e545754..000000000000 --- a/packages/angular_devkit/build_angular/test/tslint/works_spec_large.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * @license - * 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 - */ - -import { DefaultTimeout, TestLogger, runTargetSpec } from '@angular-devkit/architect/testing'; -import { normalize, virtualFs } from '@angular-devkit/core'; -import { tap } from 'rxjs/operators'; -import { TslintBuilderOptions } from '../../src'; -import { host, tslintTargetSpec } from '../utils'; - - -describe('Tslint Target', () => { - const filesWithErrors = { 'src/foo.ts': 'const foo = "";\n' }; - - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - runTargetSpec(host, tslintTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }, 30000); - - it('should report lint error once', (done) => { - host.writeMultipleFiles({'src/app/app.component.ts': 'const foo = "";\n' }); - const logger = new TestLogger('lint-error'); - - runTargetSpec(host, tslintTargetSpec, undefined, DefaultTimeout, logger).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(false)), - tap(() => { - // this is to make sure there are no duplicates - expect(logger.includes(`" should be \'\nERROR`)).toBe(false); - - expect(logger.includes(`" should be '`)).toBe(true); - expect(logger.includes(`Lint errors found in the listed files`)).toBe(true); - }), - ).toPromise().then(done, done.fail); - }, 30000); - - it('supports exclude with glob', (done) => { - host.writeMultipleFiles(filesWithErrors); - const overrides: Partial = { exclude: ['**/foo.ts'] }; - - runTargetSpec(host, tslintTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }, 30000); - - it('supports exclude with relative paths', (done) => { - host.writeMultipleFiles(filesWithErrors); - const overrides: Partial = { exclude: ['src/foo.ts'] }; - - runTargetSpec(host, tslintTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }, 30000); - - it(`supports exclude with paths starting with './'`, (done) => { - host.writeMultipleFiles(filesWithErrors); - const overrides: Partial = { exclude: ['./src/foo.ts'] }; - - runTargetSpec(host, tslintTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }, 30000); - - it('supports fix', (done) => { - host.writeMultipleFiles(filesWithErrors); - const overrides: Partial = { fix: true }; - - runTargetSpec(host, tslintTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - const fileName = normalize('src/foo.ts'); - const content = virtualFs.fileBufferToString(host.scopedSync().read(fileName)); - expect(content).toContain(`const foo = '';`); - }), - ).toPromise().then(done, done.fail); - }, 30000); - - it('supports force', (done) => { - host.writeMultipleFiles(filesWithErrors); - const logger = new TestLogger('lint-force'); - const overrides: Partial = { force: true }; - - runTargetSpec(host, tslintTargetSpec, overrides, DefaultTimeout, logger).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - expect(logger.includes(`" should be '`)).toBe(true); - expect(logger.includes(`Lint errors found in the listed files`)).toBe(true); - }), - ).toPromise().then(done, done.fail); - }, 30000); - - it('supports format', (done) => { - host.writeMultipleFiles(filesWithErrors); - const logger = new TestLogger('lint-format'); - const overrides: Partial = { format: 'stylish' }; - - runTargetSpec(host, tslintTargetSpec, overrides, DefaultTimeout, logger).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(false)), - tap(() => { - expect(logger.includes(`quotemark`)).toBe(true); - }), - ).toPromise().then(done, done.fail); - }, 30000); - - it('supports finding configs', (done) => { - host.writeMultipleFiles({ - 'src/app/foo/foo.ts': `const foo = '';\n`, - 'src/app/foo/tslint.json': ` - { - "rules": { - "quotemark": [ - true, - "double" - ] - } - } - `, - }); - const overrides: Partial = { tslintConfig: undefined }; - - runTargetSpec(host, tslintTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(false)), - ).toPromise().then(done, done.fail); - }, 30000); - - it('supports overriding configs', (done) => { - host.writeMultipleFiles({ - 'src/app/foo/foo.ts': `const foo = '';\n`, - 'src/app/foo/tslint.json': ` - { - "rules": { - "quotemark": [ - true, - "double" - ] - } - } - `, - }); - const overrides: Partial = { tslintConfig: 'tslint.json' }; - - runTargetSpec(host, tslintTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }, 30000); - - it('supports using files with no project', (done) => { - const overrides: Partial = { - tsConfig: undefined, - files: ['src/app/**/*.ts'], - }; - - runTargetSpec(host, tslintTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }, 30000); - - it('supports using one project as a string', (done) => { - const overrides: Partial = { - tsConfig: 'src/tsconfig.app.json', - }; - - runTargetSpec(host, tslintTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }, 30000); - - it('supports using one project as an array', (done) => { - const overrides: Partial = { - tsConfig: ['src/tsconfig.app.json'], - }; - - runTargetSpec(host, tslintTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }, 30000); - - it('supports using two projects', (done) => { - const overrides: Partial = { - tsConfig: ['src/tsconfig.app.json', 'src/tsconfig.spec.json'], - }; - - runTargetSpec(host, tslintTargetSpec, overrides).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }, 30000); - - it('errors when type checking is used without a project', (done) => { - const overrides: Partial = { - tsConfig: undefined, - typeCheck: true, - }; - - runTargetSpec(host, tslintTargetSpec, overrides) - .subscribe(undefined, () => done(), done.fail); - }, 30000); -}); diff --git a/packages/angular_devkit/build_angular/test/utils.ts b/packages/angular_devkit/build_angular/test/utils.ts deleted file mode 100644 index 72a11bd0a605..000000000000 --- a/packages/angular_devkit/build_angular/test/utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @license - * 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 - */ - -import { TestProjectHost } from '@angular-devkit/architect/testing'; -import { Path, join, normalize } from '@angular-devkit/core'; - - -const devkitRoot = normalize((global as any)._DevKitRoot); // tslint:disable-line:no-any -const workspaceRoot = join(devkitRoot, 'tests/angular_devkit/build_angular/hello-world-app/'); -export const host = new TestProjectHost(workspaceRoot); -export const outputPath: Path = normalize('dist'); - -export const browserTargetSpec = { project: 'app', target: 'build' }; -export const devServerTargetSpec = { project: 'app', target: 'serve' }; -export const extractI18nTargetSpec = { project: 'app', target: 'extract-i18n' }; -export const karmaTargetSpec = { project: 'app', target: 'test' }; -export const tslintTargetSpec = { project: 'app', target: 'lint' }; -export const protractorTargetSpec = { project: 'app-e2e', target: 'e2e' }; diff --git a/packages/angular_devkit/build_ng_packagr/README.md b/packages/angular_devkit/build_ng_packagr/README.md deleted file mode 100644 index acfeaea95427..000000000000 --- a/packages/angular_devkit/build_ng_packagr/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Angular Build Architect for ng-packagr - -WIP \ No newline at end of file diff --git a/packages/angular_devkit/build_ng_packagr/builders.json b/packages/angular_devkit/build_ng_packagr/builders.json deleted file mode 100644 index 58db53b1c488..000000000000 --- a/packages/angular_devkit/build_ng_packagr/builders.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "../architect/src/builders-schema.json", - "builders": { - "build": { - "class": "./src/build", - "schema": "./src/build/schema.json", - "description": "Build a library with ng-packagr." - } - } -} diff --git a/packages/angular_devkit/build_ng_packagr/package.json b/packages/angular_devkit/build_ng_packagr/package.json deleted file mode 100644 index 4b9668125eab..000000000000 --- a/packages/angular_devkit/build_ng_packagr/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@angular-devkit/build-ng-packagr", - "version": "0.0.0", - "description": "Angular Build Architect for ng-packagr", - "main": "src/index.js", - "typings": "src/index.d.ts", - "builders": "builders.json", - "dependencies": { - "@angular-devkit/architect": "0.0.0", - "@angular-devkit/core": "0.0.0", - "rxjs": "6.3.3", - "semver": "5.5.1" - }, - "peerDependencies": { - "ng-packagr": "^2.2.0 || ^3.0.0 || ^4.0.0" - }, - "devDependencies": { - "@angular/compiler": "^7.0.0-rc.0", - "@angular/compiler-cli": "^7.0.0-rc.0", - "ng-packagr": "^4.2.0", - "tsickle": ">=0.27.3", - "tslib": "^1.9.0" - } -} diff --git a/packages/angular_devkit/build_ng_packagr/src/build/index.ts b/packages/angular_devkit/build_ng_packagr/src/build/index.ts deleted file mode 100644 index ff1e60cc4fad..000000000000 --- a/packages/angular_devkit/build_ng_packagr/src/build/index.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * @license - * 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 - */ - -import { - BuildEvent, - Builder, - BuilderConfiguration, - BuilderContext, -} from '@angular-devkit/architect'; -import { getSystemPath, normalize, resolve, tags } from '@angular-devkit/core'; -import * as fs from 'fs'; -import * as ngPackagr from 'ng-packagr'; -import { EMPTY, Observable } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; -import * as semver from 'semver'; - -const NEW_NG_PACKAGR_VERSION = '4.0.0-rc.3'; - -// TODO move this function to architect or somewhere else where it can be imported from. -// Blatantly copy-pasted from 'require-project-module.ts'. -function requireProjectModule(root: string, moduleName: string) { - return require(require.resolve(moduleName, { paths: [root] })); -} - -function resolveProjectModule(root: string, moduleName: string) { - return require.resolve(moduleName, { paths: [root] }); -} - -export interface NgPackagrBuilderOptions { - project: string; - tsConfig?: string; - watch?: boolean; -} - -function checkNgPackagrVersion(projectRoot: string): boolean { - let ngPackagrJsonPath; - - try { - ngPackagrJsonPath = resolveProjectModule(projectRoot, 'ng-packagr/package.json'); - } catch { - // ng-packagr is not installed - throw new Error(tags.stripIndent` - ng-packagr is not installed. Run \`npm install ng-packagr --save-dev\` and try again. - `); - } - - const ngPackagrPackageJson = fs.readFileSync(ngPackagrJsonPath).toString(); - const ngPackagrVersion = JSON.parse(ngPackagrPackageJson)['version']; - - if (!semver.gte(ngPackagrVersion, NEW_NG_PACKAGR_VERSION)) { - throw new Error(tags.stripIndent` - The installed version of ng-packagr is ${ngPackagrVersion}. The watch feature - requires ng-packagr version to satisfy ${NEW_NG_PACKAGR_VERSION}. - Please upgrade your ng-packagr version. - `); - } - - return true; -} - -export class NgPackagrBuilder implements Builder { - - constructor(public context: BuilderContext) { } - - run(builderConfig: BuilderConfiguration): Observable { - const root = this.context.workspace.root; - const options = builderConfig.options; - - if (!options.project) { - throw new Error('A "project" must be specified to build a library\'s npm package.'); - } - - return new Observable(obs => { - const projectNgPackagr = requireProjectModule( - getSystemPath(root), 'ng-packagr') as typeof ngPackagr; - const packageJsonPath = getSystemPath(resolve(root, normalize(options.project))); - - const ngPkgProject = projectNgPackagr.ngPackagr() - .forProject(packageJsonPath); - - if (options.tsConfig) { - const tsConfigPath = getSystemPath(resolve(root, normalize(options.tsConfig))); - ngPkgProject.withTsConfig(tsConfigPath); - } - - if (options.watch) { - checkNgPackagrVersion(getSystemPath(root)); - - const ngPkgSubscription = ngPkgProject - .watch() - .pipe( - tap(() => obs.next({ success: true })), - catchError(e => { - obs.error(e); - - return EMPTY; - }), - ) - .subscribe(); - - return () => ngPkgSubscription.unsubscribe(); - } else { - ngPkgProject.build() - .then(() => { - obs.next({ success: true }); - obs.complete(); - }) - .catch(e => obs.error(e)); - } - }); - } - -} - -export default NgPackagrBuilder; diff --git a/packages/angular_devkit/build_ng_packagr/src/build/index_spec_large.ts b/packages/angular_devkit/build_ng_packagr/src/build/index_spec_large.ts deleted file mode 100644 index c227e2f67e93..000000000000 --- a/packages/angular_devkit/build_ng_packagr/src/build/index_spec_large.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * @license - * 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 - */ - -import { TargetSpecifier } from '@angular-devkit/architect'; -import { TestProjectHost, runTargetSpec } from '@angular-devkit/architect/testing'; -import { join, normalize, virtualFs } from '@angular-devkit/core'; -import { debounceTime, map, take, tap } from 'rxjs/operators'; - -const devkitRoot = normalize((global as any)._DevKitRoot); // tslint:disable-line:no-any -const workspaceRoot = join(devkitRoot, 'tests/angular_devkit/build_ng_packagr/ng-packaged/'); -export const host = new TestProjectHost(workspaceRoot); - -describe('NgPackagr Builder', () => { - beforeEach(done => host.initialize().toPromise().then(done, done.fail)); - afterEach(done => host.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - const targetSpec: TargetSpecifier = { project: 'lib', target: 'build' }; - - runTargetSpec(host, targetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }); - - it('tests works', (done) => { - const targetSpec: TargetSpecifier = { project: 'lib', target: 'test' }; - - runTargetSpec(host, targetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }); - - it('lint works', (done) => { - const targetSpec: TargetSpecifier = { project: 'lib', target: 'lint' }; - - runTargetSpec(host, targetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - ).toPromise().then(done, done.fail); - }); - - it('rebuilds on TS file changes', (done) => { - const targetSpec: TargetSpecifier = { project: 'lib', target: 'build' }; - - const goldenValueFiles: { [path: string]: string } = { - 'projects/lib/src/lib/lib.component.ts': ` - import { Component } from '@angular/core'; - - @Component({ - selector: 'lib', - template: 'lib update works!' - }) - export class LibComponent { } - `, - }; - - const overrides = { watch: true }; - - let buildNumber = 0; - - runTargetSpec(host, targetSpec, overrides) - .pipe( - // We must debounce on watch mode because file watchers are not very accurate. - // Changes from just before a process runs can be picked up and cause rebuilds. - // In this case, cleanup from the test right before this one causes a few rebuilds. - debounceTime(1000), - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - map(() => { - const fileName = './dist/lib/fesm5/lib.js'; - const content = virtualFs.fileBufferToString( - host.scopedSync().read(normalize(fileName)), - ); - - return content; - }), - tap(content => { - buildNumber += 1; - switch (buildNumber) { - case 1: - expect(content).toMatch(/lib works/); - host.writeMultipleFiles(goldenValueFiles); - break; - - case 2: - expect(content).toMatch(/lib update works/); - break; - default: - break; - } - }), - take(2), - ) - .toPromise() - .then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/build_ng_packagr/src/build/schema.json b/packages/angular_devkit/build_ng_packagr/src/build/schema.json deleted file mode 100644 index 979b5cd25599..000000000000 --- a/packages/angular_devkit/build_ng_packagr/src/build/schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "title": "ng-packagr Target", - "description": "ng-packagr target options for Build Architect.", - "type": "object", - "properties": { - "project": { - "type": "string", - "description": "The file path of the package.json for distribution via npm." - }, - "tsConfig": { - "type": "string", - "description": "The file path of the TypeScript configuration file." - }, - "watch": { - "type": "boolean", - "description": "Run build when files change.", - "default": false - } - }, - "additionalProperties": false, - "required": [ - "project" - ] -} diff --git a/packages/angular_devkit/build_ng_packagr/src/index.ts b/packages/angular_devkit/build_ng_packagr/src/index.ts deleted file mode 100644 index 51ed774b37fc..000000000000 --- a/packages/angular_devkit/build_ng_packagr/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @license - * 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 - */ - -export * from './build'; diff --git a/packages/angular_devkit/build_optimizer/BUILD b/packages/angular_devkit/build_optimizer/BUILD deleted file mode 100644 index a733b2a06d36..000000000000 --- a/packages/angular_devkit/build_optimizer/BUILD +++ /dev/null @@ -1,48 +0,0 @@ -# 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 - -licenses(["notice"]) # MIT - -load("@build_bazel_rules_typescript//:defs.bzl", "ts_library") -load("@build_bazel_rules_nodejs//:defs.bzl", "jasmine_node_test") - -package(default_visibility = ["//visibility:public"]) - -ts_library( - name = "build_optimizer", - srcs = glob( - include = ["src/**/*.ts"], - exclude = [ - # TODO(@filipesilva): shouldn't need to exclude the cli files but can't exclude them - # from jasmine_node_test. - "src/**/cli.ts", - "src/**/*_spec.ts", - "src/**/*_spec_large.ts", - "src/**/*_benchmark.ts", - ], - ), - deps = [], -) - -ts_library( - name = "build_optimizer_test_lib", - srcs = glob( - include = [ - "src/**/*_spec.ts", - "src/**/*_spec_large.ts", - ], - ), - deps = [ - ":build_optimizer", - "//packages/angular_devkit/core", - # @typings: jasmine - # @typings: node - ], -) - -jasmine_node_test( - name = "build_optimizer_test", - srcs = [":build_optimizer_test_lib"], -) \ No newline at end of file diff --git a/packages/angular_devkit/build_optimizer/README.md b/packages/angular_devkit/build_optimizer/README.md deleted file mode 100644 index ad15925c6a94..000000000000 --- a/packages/angular_devkit/build_optimizer/README.md +++ /dev/null @@ -1,210 +0,0 @@ -# Angular Build Optimizer - -Angular Build Optimizer contains Angular optimizations applicable to JavaScript code as a TypeScript transform pipeline. - - -## Available optimizations - -Transformations applied depend on file content: - -- [Class fold](#class-fold), [Scrub file](#scrub-file) and [Prefix functions](#prefix-functions): applied to Angular apps and libraries. -- [Import tslib](#import-tslib): applied when TypeScript helpers are found. - -Some of these optimizations add `/*@__PURE__*/` comments. -These are used by [UglifyJS](https://github.com/mishoo/UglifyJS2) to identify pure functions that can potentially be dropped. - - -### Class fold - -Static properties are folded into ES5 classes: - -```typescript -// input -var Clazz = (function () { function Clazz() { } return Clazz; }()); -Clazz.prop = 1; - -// output -var Clazz = (function () { function Clazz() { } Clazz.prop = 1; return Clazz; }()); -``` - - -### Scrub file - -Angular decorators, property decorators and constructor parameters are removed, while leaving non-Angular ones intact. - -```typescript -// input -import { Injectable, Input, Component } from '@angular/core'; -import { NotInjectable, NotComponent, NotInput } from 'another-lib'; -var Clazz = (function () { function Clazz() { } return Clazz; }()); -Clazz.decorators = [{ type: Injectable }, { type: NotInjectable }]; -Clazz.propDecorators = { 'ngIf': [{ type: Input }] }; -Clazz.ctorParameters = function () { return [{type: Injector}]; }; -var ComponentClazz = (function () { - function ComponentClazz() { } - __decorate([ - Input(), - __metadata("design:type", Object) - ], Clazz.prototype, "selected", void 0); - __decorate([ - NotInput(), - __metadata("design:type", Object) - ], Clazz.prototype, "notSelected", void 0); - ComponentClazz = __decorate([ - NotComponent(), - Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.css'] - }) - ], ComponentClazz); - return ComponentClazz; -}()); - -// output -import { Injectable, Input, Component } from '@angular/core'; -import { NotInjectable, NotComponent } from 'another-lib'; -var Clazz = (function () { function Clazz() { } return Clazz; }()); -Clazz.decorators = [{ type: NotInjectable }]; -var ComponentClazz = (function () { - function ComponentClazz() { } - __decorate([ - NotInput(), - __metadata("design:type", Object) - ], Clazz.prototype, "notSelected", void 0); - ComponentClazz = __decorate([ - NotComponent() - ], ComponentClazz); - return ComponentClazz; -}()); -``` - - -### Prefix functions - -Adds `/*@__PURE__*/` comments to top level downleveled class declarations and instantiation. - -Warning: this transform assumes the file is a pure module. It should not be used with unpure modules. - -```typescript -// input -var Clazz = (function () { function Clazz() { } return Clazz; }()); -var newClazz = new Clazz(); -var newClazzTwo = Clazz(); - -// output -var Clazz = /*@__PURE__*/ (function () { function Clazz() { } return Clazz; }()); -var newClazz = /*@__PURE__*/ new Clazz(); -var newClazzTwo = /*@__PURE__*/ Clazz(); -``` - - -### Prefix Classes - -Adds `/*@__PURE__*/` to downleveled TypeScript classes. - -```typescript -// input -var ReplayEvent = (function () { - function ReplayEvent(time, value) { - this.time = time; - this.value = value; - } - return ReplayEvent; -}()); - -// output -var ReplayEvent = /*@__PURE__*/ (function () { - function ReplayEvent(time, value) { - this.time = time; - this.value = value; - } - return ReplayEvent; -}()); -``` - - -### Import tslib - -TypeScript helpers (`__extends/__decorate/__metadata/__param`) are replaced with `tslib` imports whenever found. - -```typescript -// input -var __extends = (this && this.__extends) || function (d, b) { - for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; - function __() { this.constructor = d; } - d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); -}; - -// output -import { __extends } from "tslib"; -``` - -### Wrap enums - -Wrap downleveled TypeScript enums in a function, and adds `/*@__PURE__*/` comment. - -```typescript -// input -var ChangeDetectionStrategy; -(function (ChangeDetectionStrategy) { - ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; - ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; -})(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})); - -// output -var ChangeDetectionStrategy = /*@__PURE__*/ (function () { - var ChangeDetectionStrategy = {}; - ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; - ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; - return ChangeDetectionStrategy; -})(); -``` - - -## Library Usage - -```typescript -import { buildOptimizer } from '@angular-devkit/build-optimizer'; - -const transpiledContent = buildOptimizer({ content: input }).content; -``` - -Available options: -```typescript -export interface BuildOptimizerOptions { - content?: string; - inputFilePath?: string; - outputFilePath?: string; - emitSourceMap?: boolean; - strict?: boolean; - isSideEffectFree?: boolean; -} -``` - - -## Webpack loader usage: - -```typescript -module.exports = { - module: { - rules: [ - { - test: /\.js$/, - loader: '@angular-devkit/build-optimizer/webpack-loader', - options: { - sourceMap: false - } - } - ] - } -} -``` - - -## CLI usage - -```bash -build-optimizer input.js -build-optimizer input.js output.js -``` diff --git a/packages/angular_devkit/build_optimizer/package.json b/packages/angular_devkit/build_optimizer/package.json deleted file mode 100644 index 7dd4920aa4ef..000000000000 --- a/packages/angular_devkit/build_optimizer/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@angular-devkit/build-optimizer", - "version": "0.0.0", - "description": "Angular Build Optimizer", - "main": "src/index.js", - "typings": "src/index.d.ts", - "bin": { - "build-optimizer": "./src/build-optimizer/cli.js" - }, - "dependencies": { - "loader-utils": "1.1.0", - "source-map": "0.5.6", - "typescript": "3.1.3", - "webpack-sources": "1.2.0" - } -} diff --git a/packages/angular_devkit/build_optimizer/src/build-optimizer/build-optimizer.ts b/packages/angular_devkit/build_optimizer/src/build-optimizer/build-optimizer.ts deleted file mode 100644 index 13e69da33d83..000000000000 --- a/packages/angular_devkit/build_optimizer/src/build-optimizer/build-optimizer.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * @license - * 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 - */ -import { readFileSync } from 'fs'; -import { - TransformJavascriptOptions, - TransformJavascriptOutput, - transformJavascript, -} from '../helpers/transform-javascript'; -import { getFoldFileTransformer } from '../transforms/class-fold'; -import { getImportTslibTransformer, testImportTslib } from '../transforms/import-tslib'; -import { getPrefixClassesTransformer, testPrefixClasses } from '../transforms/prefix-classes'; -import { getPrefixFunctionsTransformer } from '../transforms/prefix-functions'; -import { getScrubFileTransformer, testScrubFile } from '../transforms/scrub-file'; -import { getWrapEnumsTransformer } from '../transforms/wrap-enums'; - - -// Angular packages are known to have no side effects. -const whitelistedAngularModules = [ - /[\\/]node_modules[\\/]@angular[\\/]animations[\\/]/, - /[\\/]node_modules[\\/]@angular[\\/]common[\\/]/, - /[\\/]node_modules[\\/]@angular[\\/]compiler[\\/]/, - /[\\/]node_modules[\\/]@angular[\\/]core[\\/]/, - /[\\/]node_modules[\\/]@angular[\\/]forms[\\/]/, - /[\\/]node_modules[\\/]@angular[\\/]http[\\/]/, - /[\\/]node_modules[\\/]@angular[\\/]platform-browser-dynamic[\\/]/, - /[\\/]node_modules[\\/]@angular[\\/]platform-browser[\\/]/, - /[\\/]node_modules[\\/]@angular[\\/]platform-webworker-dynamic[\\/]/, - /[\\/]node_modules[\\/]@angular[\\/]platform-webworker[\\/]/, - /[\\/]node_modules[\\/]@angular[\\/]router[\\/]/, - /[\\/]node_modules[\\/]@angular[\\/]upgrade[\\/]/, - /[\\/]node_modules[\\/]@angular[\\/]material[\\/]/, - /[\\/]node_modules[\\/]@angular[\\/]cdk[\\/]/, -]; - -// Factories created by AOT are known to have no side effects. -// In Angular 2/4 the file path for factories can be `.ts`, but in Angular 5 it is `.js`. -const ngFactories = [ - /\.ngfactory\.[jt]s/, - /\.ngstyle\.[jt]s/, -]; - -function isKnownSideEffectFree(filePath: string) { - return ngFactories.some((re) => re.test(filePath)) || - whitelistedAngularModules.some((re) => re.test(filePath)); -} - -export interface BuildOptimizerOptions { - content?: string; - originalFilePath?: string; - inputFilePath?: string; - outputFilePath?: string; - emitSourceMap?: boolean; - strict?: boolean; - isSideEffectFree?: boolean; -} - -export function buildOptimizer(options: BuildOptimizerOptions): TransformJavascriptOutput { - - const { inputFilePath } = options; - let { originalFilePath, content } = options; - - if (!originalFilePath && inputFilePath) { - originalFilePath = inputFilePath; - } - - if (!inputFilePath && content === undefined) { - throw new Error('Either filePath or content must be specified in options.'); - } - - if (content === undefined) { - content = readFileSync(inputFilePath as string, 'UTF-8'); - } - - if (!content) { - return { - content: null, - sourceMap: null, - emitSkipped: true, - }; - } - - const isWebpackBundle = content.indexOf('__webpack_require__') !== -1; - - // Determine which transforms to apply. - const getTransforms = []; - - let typeCheck = false; - if (options.isSideEffectFree || originalFilePath && isKnownSideEffectFree(originalFilePath)) { - getTransforms.push( - // getPrefixFunctionsTransformer is rather dangerous, apply only to known pure es5 modules. - // It will mark both `require()` calls and `console.log(stuff)` as pure. - // We only apply it to whitelisted modules, since we know they are safe. - // getPrefixFunctionsTransformer needs to be before getFoldFileTransformer. - getPrefixFunctionsTransformer, - getScrubFileTransformer, - getFoldFileTransformer, - ); - typeCheck = true; - } else if (testScrubFile(content)) { - // Always test as these require the type checker - getTransforms.push( - getScrubFileTransformer, - getFoldFileTransformer, - ); - typeCheck = true; - } - - // tests are not needed for fast path - // usage will be expanded once transformers are verified safe - const ignoreTest = !options.emitSourceMap && !typeCheck; - - if (testPrefixClasses(content)) { - getTransforms.unshift(getPrefixClassesTransformer); - } - - // This transform introduces import/require() calls, but this won't work properly on libraries - // built with Webpack. These libraries use __webpack_require__() calls instead, which will break - // with a new import that wasn't part of it's original module list. - // We ignore this transform for such libraries. - if (!isWebpackBundle && (ignoreTest || testImportTslib(content))) { - getTransforms.unshift(getImportTslibTransformer); - } - - getTransforms.unshift(getWrapEnumsTransformer); - - const transformJavascriptOpts: TransformJavascriptOptions = { - content: content, - inputFilePath: options.inputFilePath, - outputFilePath: options.outputFilePath, - emitSourceMap: options.emitSourceMap, - strict: options.strict, - getTransforms, - typeCheck, - }; - - return transformJavascript(transformJavascriptOpts); -} diff --git a/packages/angular_devkit/build_optimizer/src/build-optimizer/build-optimizer_spec.ts b/packages/angular_devkit/build_optimizer/src/build-optimizer/build-optimizer_spec.ts deleted file mode 100644 index 42768c1b15d7..000000000000 --- a/packages/angular_devkit/build_optimizer/src/build-optimizer/build-optimizer_spec.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable:no-big-function -// tslint:disable-next-line:no-implicit-dependencies -import { tags } from '@angular-devkit/core'; -import { RawSourceMap } from 'source-map'; -import { TransformJavascriptOutput } from '../helpers/transform-javascript'; -import { buildOptimizer } from './build-optimizer'; - - -describe('build-optimizer', () => { - const imports = 'import { Injectable, Input, Component } from \'@angular/core\';'; - const clazz = 'var Clazz = (function () { function Clazz() { } return Clazz; }());'; - const staticProperty = 'Clazz.prop = 1;'; - const decorators = 'Clazz.decorators = [ { type: Injectable } ];'; - - describe('basic functionality', () => { - it('applies class-fold, scrub-file and prefix-functions to side-effect free modules', () => { - const input = tags.stripIndent` - ${imports} - var __extends = (this && this.__extends) || function (d, b) { - for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; - function __() { this.constructor = d; } - d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); - }; - var ChangeDetectionStrategy; - (function (ChangeDetectionStrategy) { - ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; - ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; - })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})); - ${clazz} - ${staticProperty} - ${decorators} - Clazz.propDecorators = { 'ngIf': [{ type: Input }] }; - Clazz.ctorParameters = function () { return [{type: Injectable}]; }; - var ComponentClazz = (function () { - function ComponentClazz() { } - __decorate([ - Input(), - __metadata("design:type", Object) - ], Clazz.prototype, "selected", void 0); - ComponentClazz = __decorate([ - Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.css'] - }), - __metadata("design:paramtypes", [Injectable]) - ], ComponentClazz); - return ComponentClazz; - }()); - var RenderType_MdOption = ɵcrt({ encapsulation: 2, styles: styles_MdOption}); - `; - // tslint:disable:max-line-length - const output = tags.oneLine` - import { __extends } from "tslib"; - ${imports} - var ChangeDetectionStrategy = /*@__PURE__*/ (function (ChangeDetectionStrategy) { - ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; - ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; - return ChangeDetectionStrategy; - })({}); - var Clazz = /*@__PURE__*/ (function () { function Clazz() { } ${staticProperty} return Clazz; }()); - var ComponentClazz = /*@__PURE__*/ (function () { - function ComponentClazz() { } - return ComponentClazz; - }()); - var RenderType_MdOption = /*@__PURE__*/ ɵcrt({ encapsulation: 2, styles: styles_MdOption }); - `; - - // Check Angular 4/5 and unix/windows paths. - const inputPaths = [ - '/node_modules/@angular/core/@angular/core.es5.js', - '/node_modules/@angular/core/esm5/core.js', - '\\node_modules\\@angular\\core\\@angular\\core.es5.js', - '\\node_modules\\@angular\\core\\esm5\\core.js', - '/project/file.ngfactory.js', - '/project/file.ngstyle.js', - ]; - - inputPaths.forEach((inputFilePath) => { - const boOutput = buildOptimizer({ content: input, inputFilePath }); - expect(tags.oneLine`${boOutput.content}`).toEqual(output); - expect(boOutput.emitSkipped).toEqual(false); - }); - }); - - it('supports flagging module as side-effect free', () => { - const output = tags.oneLine` - var RenderType_MdOption = /*@__PURE__*/ ɵcrt({ encapsulation: 2, styles: styles_MdOption }); - `; - const input = tags.stripIndent` - var RenderType_MdOption = ɵcrt({ encapsulation: 2, styles: styles_MdOption}); - `; - - const boOutput = buildOptimizer({ content: input, isSideEffectFree: true }); - expect(tags.oneLine`${boOutput.content}`).toEqual(output); - expect(boOutput.emitSkipped).toEqual(false); - }); - }); - - - describe('resilience', () => { - it('doesn\'t process files with invalid syntax by default', () => { - const input = tags.oneLine` - ))))invalid syntax - ${clazz} - Clazz.decorators = [ { type: Injectable } ]; - `; - - const boOutput = buildOptimizer({ content: input }); - expect(boOutput.content).toBeFalsy(); - expect(boOutput.emitSkipped).toEqual(true); - }); - - it('throws on files with invalid syntax in strict mode', () => { - const input = tags.oneLine` - ))))invalid syntax - ${clazz} - Clazz.decorators = [ { type: Injectable } ]; - `; - - expect(() => buildOptimizer({ content: input, strict: true })).toThrow(); - }); - - // TODO: re-enable this test when updating to TypeScript >2.9.1. - // The `prefix-classes` tests will also need to be adjusted. - // See https://github.com/angular/devkit/pull/998#issuecomment-393867606 for more info. - xit(`doesn't exceed call stack size when type checking very big classes`, () => { - // BigClass with a thousand methods. - // Clazz is included with ctorParameters to trigger transforms with type checking. - const input = ` - var BigClass = /** @class */ (function () { - function BigClass() { - } - ${Array.from(new Array(1000)).map((_v, i) => - `BigClass.prototype.method${i} = function () { return this.myVar; };`, - ).join('\n')} - return BigClass; - }()); - ${clazz} - Clazz.ctorParameters = function () { return []; }; - `; - - let boOutput: TransformJavascriptOutput; - expect(() => { - boOutput = buildOptimizer({ content: input }); - expect(boOutput.emitSkipped).toEqual(false); - }).not.toThrow(); - }); - }); - - describe('whitelisted modules', () => { - // This statement is considered pure by getPrefixFunctionsTransformer on whitelisted modules. - const input = 'console.log(42);'; - const output = '/*@__PURE__*/ console.log(42);'; - - it('should process whitelisted modules', () => { - const inputFilePath = '/node_modules/@angular/core/@angular/core.es5.js'; - const boOutput = buildOptimizer({ content: input, inputFilePath }); - expect(boOutput.content).toContain(output); - expect(boOutput.emitSkipped).toEqual(false); - }); - - it('should not process non-whitelisted modules', () => { - const inputFilePath = '/node_modules/other-package/core.es5.js'; - const boOutput = buildOptimizer({ content: input, inputFilePath }); - expect(boOutput.emitSkipped).toEqual(true); - }); - - it('should not process non-whitelisted umd modules', () => { - const inputFilePath = '/node_modules/other_lib/index.js'; - const boOutput = buildOptimizer({ content: input, inputFilePath }); - expect(boOutput.emitSkipped).toEqual(true); - }); - }); - - describe('sourcemaps', () => { - const transformableInput = tags.oneLine` - ${imports} - ${clazz} - ${decorators} - `; - - it('doesn\'t produce sourcemaps by default', () => { - expect(buildOptimizer({ content: transformableInput }).sourceMap).toBeFalsy(); - }); - - it('produces sourcemaps', () => { - expect(buildOptimizer( - { content: transformableInput, emitSourceMap: true }, - ).sourceMap).toBeTruthy(); - }); - - // TODO: re-enable this test, it was temporarily disabled as part of - // https://github.com/angular/devkit/pull/842 - xit('doesn\'t produce sourcemaps when emitting was skipped', () => { - const ignoredInput = tags.oneLine` - var Clazz = (function () { function Clazz() { } return Clazz; }()); - ${staticProperty} - `; - const invalidInput = tags.oneLine` - ))))invalid syntax - ${clazz} - Clazz.decorators = [ { type: Injectable } ]; - `; - - const ignoredOutput = buildOptimizer({ content: ignoredInput, emitSourceMap: true }); - expect(ignoredOutput.emitSkipped).toBeTruthy(); - expect(ignoredOutput.sourceMap).toBeFalsy(); - - const invalidOutput = buildOptimizer({ content: invalidInput, emitSourceMap: true }); - expect(invalidOutput.emitSkipped).toBeTruthy(); - expect(invalidOutput.sourceMap).toBeFalsy(); - }); - - it('emits sources content', () => { - const sourceMap = buildOptimizer( - { content: transformableInput, emitSourceMap: true }, - ).sourceMap as RawSourceMap; - const sourceContent = sourceMap.sourcesContent as string[]; - expect(sourceContent[0]).toEqual(transformableInput); - }); - - it('uses empty strings if inputFilePath and outputFilePath is not provided', () => { - const { content, sourceMap } = buildOptimizer( - { content: transformableInput, emitSourceMap: true }); - - if (!sourceMap) { - throw new Error('sourceMap was not generated.'); - } - expect(sourceMap.file).toEqual(''); - expect(sourceMap.sources[0]).toEqual(''); - expect(content).not.toContain(`sourceMappingURL`); - }); - - it('uses inputFilePath and outputFilePath if provided', () => { - const inputFilePath = '/path/to/file.js'; - const outputFilePath = '/path/to/file.bo.js'; - const { content, sourceMap } = buildOptimizer({ - content: transformableInput, - emitSourceMap: true, - inputFilePath, - outputFilePath, - }); - - if (!sourceMap) { - throw new Error('sourceMap was not generated.'); - } - expect(sourceMap.file).toEqual(outputFilePath); - expect(sourceMap.sources[0]).toEqual(inputFilePath); - expect(content).toContain(`sourceMappingURL=${outputFilePath}.map`); - }); - }); - -}); diff --git a/packages/angular_devkit/build_optimizer/src/build-optimizer/cli.ts b/packages/angular_devkit/build_optimizer/src/build-optimizer/cli.ts deleted file mode 100644 index 3b669935b4c6..000000000000 --- a/packages/angular_devkit/build_optimizer/src/build-optimizer/cli.ts +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env node -/** - * @license - * 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 - */ -import { writeFileSync } from 'fs'; -import { join } from 'path'; -import { buildOptimizer } from './build-optimizer'; - - -if (process.argv.length < 3 || process.argv.length > 4) { - throw new Error(` - build-optimizer should be called with either one or two arguments: - - build-optimizer input.js - build-optimizer input.js output.js - `); -} - -const currentDir = process.cwd(); - -const inputFile = process.argv[2]; -const tsOrJsRegExp = /\.(j|t)s$/; - -if (!inputFile.match(tsOrJsRegExp)) { - throw new Error(`Input file must be .js or .ts.`); -} - -// Use provided output file, or add the .bo suffix before the extension. -const outputFile = process.argv[3] || inputFile.replace(tsOrJsRegExp, (subStr) => `.bo${subStr}`); - -const boOutput = buildOptimizer({ - inputFilePath: join(currentDir, inputFile), - outputFilePath: join(currentDir, outputFile), - emitSourceMap: true, -}); - -if (boOutput.emitSkipped) { - console.log('Nothing to emit.'); -} else { - writeFileSync(join(currentDir, outputFile), boOutput.content); - writeFileSync(join(currentDir, `${outputFile}.map`), JSON.stringify(boOutput.sourceMap)); - console.log('Emitted:'); - console.log(` ${outputFile}`); - console.log(` ${outputFile}.map`); -} diff --git a/packages/angular_devkit/build_optimizer/src/build-optimizer/rollup-plugin.ts b/packages/angular_devkit/build_optimizer/src/build-optimizer/rollup-plugin.ts deleted file mode 100644 index de02a159f6ab..000000000000 --- a/packages/angular_devkit/build_optimizer/src/build-optimizer/rollup-plugin.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @license - * 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 - */ - -/** - * @fileoverview This adapts the buildOptimizer to run over each file as it is - * processed by Rollup. We must do this since buildOptimizer expects to see the - * ESModules in the input sources, and therefore cannot run on the rollup output - */ - -import * as path from 'path'; -import { RawSourceMap } from 'source-map'; -import { buildOptimizer } from './build-optimizer'; - -const DEBUG = false; - -export interface Options { - sideEffectFreeModules?: string[]; -} - -export default function optimizer(options: Options) { - return { - name: 'build-optimizer', - transform: (content: string, id: string): {code: string, map: RawSourceMap}|null => { - const isSideEffectFree = options.sideEffectFreeModules && - options.sideEffectFreeModules.some(m => id.indexOf(m) >= 0); - const { content: code, sourceMap: map } = buildOptimizer({ - content, inputFilePath: id, emitSourceMap: true, isSideEffectFree, - }); - if (!code) { - if (DEBUG) { - console.error('no transforms produced by buildOptimizer for ' - + path.relative(process.cwd(), id)); - } - - return null; - } - if (!map) { - throw new Error('no sourcemap produced by buildOptimizer'); - } - - return { code, map }; - }, - }; -} diff --git a/packages/angular_devkit/build_optimizer/src/build-optimizer/webpack-loader.ts b/packages/angular_devkit/build_optimizer/src/build-optimizer/webpack-loader.ts deleted file mode 100644 index e05666f8b596..000000000000 --- a/packages/angular_devkit/build_optimizer/src/build-optimizer/webpack-loader.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * @license - * 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 - */ -import { RawSourceMap, SourceMapConsumer, SourceMapGenerator } from 'source-map'; -import * as webpack from 'webpack'; // tslint:disable-line:no-implicit-dependencies - -const loaderUtils = require('loader-utils'); - -import { buildOptimizer } from './build-optimizer'; - - -interface BuildOptimizerLoaderOptions { - sourceMap: boolean; -} - -export default function buildOptimizerLoader - (this: webpack.loader.LoaderContext, content: string, previousSourceMap: RawSourceMap) { - this.cacheable(); - const options: BuildOptimizerLoaderOptions = loaderUtils.getOptions(this) || {}; - - // Make up names of the intermediate files so we can chain the sourcemaps. - const inputFilePath = this.resourcePath + '.pre-build-optimizer.js'; - const outputFilePath = this.resourcePath + '.post-build-optimizer.js'; - - const boOutput = buildOptimizer({ - content, - originalFilePath: this.resourcePath, - inputFilePath, - outputFilePath, - emitSourceMap: options.sourceMap, - isSideEffectFree: this._module - && this._module.factoryMeta - && this._module.factoryMeta.sideEffectFree, - }); - - if (boOutput.emitSkipped || boOutput.content === null) { - // Webpack typings for previousSourceMap are wrong, they are JSON objects and not strings. - // tslint:disable-next-line:no-any - this.callback(null, content, previousSourceMap as any); - - return; - } - - const intermediateSourceMap = boOutput.sourceMap; - let newContent = boOutput.content; - - let newSourceMap; - - if (options.sourceMap && intermediateSourceMap) { - // Webpack doesn't need sourceMappingURL since we pass them on explicitely. - newContent = newContent.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''); - - if (previousSourceMap) { - // If there's a previous sourcemap, we have to chain them. - // See https://github.com/mozilla/source-map/issues/216#issuecomment-150839869 for a simple - // source map chaining example. - // Use http://sokra.github.io/source-map-visualization/ to validate sourcemaps make sense. - - // Force the previous sourcemap to use the filename we made up for it. - // In order for source maps to be chained, the consumed source map `file` needs to be in the - // consumers source map `sources` array. - previousSourceMap.file = inputFilePath; - - // Chain the sourcemaps. - const consumer = new SourceMapConsumer(intermediateSourceMap); - const generator = SourceMapGenerator.fromSourceMap(consumer); - generator.applySourceMap(new SourceMapConsumer(previousSourceMap)); - newSourceMap = generator.toJSON(); - } else { - // Otherwise just return our generated sourcemap. - newSourceMap = intermediateSourceMap; - } - } - - // Webpack typings for previousSourceMap are wrong, they are JSON objects and not strings. - // tslint:disable-next-line:no-any - this.callback(null, newContent, newSourceMap as any); -} diff --git a/packages/angular_devkit/build_optimizer/src/helpers/ast-utils.ts b/packages/angular_devkit/build_optimizer/src/helpers/ast-utils.ts deleted file mode 100644 index 131dfbf8d420..000000000000 --- a/packages/angular_devkit/build_optimizer/src/helpers/ast-utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @license - * 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 - */ -import * as ts from 'typescript'; - -// Find all nodes from the AST in the subtree of node of SyntaxKind kind. -export function collectDeepNodes(node: ts.Node, kind: ts.SyntaxKind): T[] { - const nodes: T[] = []; - const helper = (child: ts.Node) => { - if (child.kind === kind) { - nodes.push(child as T); - } - ts.forEachChild(child, helper); - }; - ts.forEachChild(node, helper); - - return nodes; -} diff --git a/packages/angular_devkit/build_optimizer/src/helpers/transform-javascript.ts b/packages/angular_devkit/build_optimizer/src/helpers/transform-javascript.ts deleted file mode 100644 index efd568e70f21..000000000000 --- a/packages/angular_devkit/build_optimizer/src/helpers/transform-javascript.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * @license - * 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 - */ -import { RawSourceMap } from 'source-map'; -import * as ts from 'typescript'; - - -export interface TransformJavascriptOptions { - content: string; - inputFilePath?: string; - outputFilePath?: string; - emitSourceMap?: boolean; - strict?: boolean; - typeCheck?: boolean; - getTransforms: Array<(program?: ts.Program) => ts.TransformerFactory>; -} - -export interface TransformJavascriptOutput { - content: string | null; - sourceMap: RawSourceMap | null; - emitSkipped: boolean; -} - -interface DiagnosticSourceFile extends ts.SourceFile { - readonly parseDiagnostics?: ReadonlyArray; -} - -function validateDiagnostics(diagnostics: ReadonlyArray, strict?: boolean): boolean { - // Print error diagnostics. - - const hasError = diagnostics.some(diag => diag.category === ts.DiagnosticCategory.Error); - if (hasError) { - // Throw only if we're in strict mode, otherwise return original content. - if (strict) { - const errorMessages = ts.formatDiagnostics(diagnostics, { - getCurrentDirectory: () => ts.sys.getCurrentDirectory(), - getNewLine: () => ts.sys.newLine, - getCanonicalFileName: (f: string) => f, - }); - - throw new Error(` - TS failed with the following error messages: - - ${errorMessages} - `); - } else { - return false; - } - } - - return true; -} - -export function transformJavascript( - options: TransformJavascriptOptions, -): TransformJavascriptOutput { - - const { - content, - getTransforms, - emitSourceMap, - inputFilePath, - outputFilePath, - strict, - } = options; - - // Bail if there's no transform to do. - if (getTransforms.length === 0) { - return { - content: null, - sourceMap: null, - emitSkipped: true, - }; - } - - const allowFastPath = options.typeCheck === false && !emitSourceMap; - const outputs = new Map(); - const tempFilename = 'bo-default-file.js'; - const tempSourceFile = ts.createSourceFile( - tempFilename, - content, - ts.ScriptTarget.Latest, - allowFastPath, - ); - const parseDiagnostics = (tempSourceFile as DiagnosticSourceFile).parseDiagnostics; - - const tsOptions: ts.CompilerOptions = { - // We target latest so that there is no downleveling. - target: ts.ScriptTarget.Latest, - isolatedModules: true, - suppressOutputPathCheck: true, - allowNonTsExtensions: true, - noLib: true, - noResolve: true, - sourceMap: emitSourceMap, - inlineSources: emitSourceMap, - inlineSourceMap: false, - }; - - if (allowFastPath && parseDiagnostics) { - if (!validateDiagnostics(parseDiagnostics, strict)) { - return { - content: null, - sourceMap: null, - emitSkipped: true, - }; - } - - const transforms = getTransforms.map((getTf) => getTf(undefined)); - - const result = ts.transform(tempSourceFile, transforms, tsOptions); - if (result.transformed.length === 0 || result.transformed[0] === tempSourceFile) { - return { - content: null, - sourceMap: null, - emitSkipped: true, - }; - } - - const printer = ts.createPrinter( - undefined, - { - onEmitNode: result.emitNodeWithNotification, - substituteNode: result.substituteNode, - }, - ); - - const output = printer.printFile(result.transformed[0]); - - result.dispose(); - - return { - content: output, - sourceMap: null, - emitSkipped: false, - }; - } - - const host: ts.CompilerHost = { - getSourceFile: (fileName) => { - if (fileName !== tempFilename) { - throw new Error(`File ${fileName} does not have a sourceFile.`); - } - - return tempSourceFile; - }, - getDefaultLibFileName: () => 'lib.d.ts', - getCurrentDirectory: () => '', - getDirectories: () => [], - getCanonicalFileName: (fileName) => fileName, - useCaseSensitiveFileNames: () => true, - getNewLine: () => '\n', - fileExists: (fileName) => fileName === tempFilename, - readFile: (_fileName) => '', - writeFile: (fileName, text) => outputs.set(fileName, text), - }; - - const program = ts.createProgram([tempFilename], tsOptions, host); - - const diagnostics = program.getSyntacticDiagnostics(tempSourceFile); - if (!validateDiagnostics(diagnostics, strict)) { - return { - content: null, - sourceMap: null, - emitSkipped: true, - }; - } - - // We need the checker inside transforms. - const transforms = getTransforms.map((getTf) => getTf(program)); - - program.emit(undefined, undefined, undefined, undefined, { before: transforms, after: [] }); - - let transformedContent = outputs.get(tempFilename); - - if (!transformedContent) { - return { - content: null, - sourceMap: null, - emitSkipped: true, - }; - } - - let sourceMap: RawSourceMap | null = null; - const tsSourceMap = outputs.get(`${tempFilename}.map`); - - if (emitSourceMap && tsSourceMap) { - const urlRegExp = /^\/\/# sourceMappingURL=[^\r\n]*/gm; - sourceMap = JSON.parse(tsSourceMap) as RawSourceMap; - // Fix sourcemaps file references. - if (outputFilePath) { - sourceMap.file = outputFilePath; - transformedContent = transformedContent.replace(urlRegExp, - `//# sourceMappingURL=${sourceMap.file}.map\n`); - if (inputFilePath) { - sourceMap.sources = [inputFilePath]; - } else { - sourceMap.sources = ['']; - } - } else { - // TODO: figure out if we should inline sources here. - transformedContent = transformedContent.replace(urlRegExp, ''); - sourceMap.file = ''; - sourceMap.sources = ['']; - } - } - - return { - content: transformedContent, - sourceMap, - emitSkipped: false, - }; -} diff --git a/packages/angular_devkit/build_optimizer/src/index.ts b/packages/angular_devkit/build_optimizer/src/index.ts deleted file mode 100644 index 1cc3bda13f17..000000000000 --- a/packages/angular_devkit/build_optimizer/src/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * @license - * 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 - */ -export { default as buildOptimizerLoader } from './build-optimizer/webpack-loader'; -export { buildOptimizer } from './build-optimizer/build-optimizer'; - -export { transformJavascript } from './helpers/transform-javascript'; - -export { getFoldFileTransformer } from './transforms/class-fold'; -export { getImportTslibTransformer, testImportTslib } from './transforms/import-tslib'; -export { getPrefixClassesTransformer, testPrefixClasses } from './transforms/prefix-classes'; -export { getPrefixFunctionsTransformer } from './transforms/prefix-functions'; -export { getScrubFileTransformer, testScrubFile } from './transforms/scrub-file'; -export { getWrapEnumsTransformer } from './transforms/wrap-enums'; diff --git a/packages/angular_devkit/build_optimizer/src/transforms/class-fold.ts b/packages/angular_devkit/build_optimizer/src/transforms/class-fold.ts deleted file mode 100644 index 0b466f66b78c..000000000000 --- a/packages/angular_devkit/build_optimizer/src/transforms/class-fold.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * @license - * 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 - */ -import * as ts from 'typescript'; - -interface ClassData { - name: string; - class: ts.VariableDeclaration; - classFunction: ts.FunctionExpression; - statements: StatementData[]; -} - -interface StatementData { - expressionStatement: ts.ExpressionStatement; - hostClass: ClassData; -} - -export function getFoldFileTransformer(program: ts.Program): ts.TransformerFactory { - const checker = program.getTypeChecker(); - - return (context: ts.TransformationContext): ts.Transformer => { - - const transformer: ts.Transformer = (sf: ts.SourceFile) => { - - const classes = findClassDeclarations(sf); - const statements = findClassStaticPropertyAssignments(sf, checker, classes); - - const visitor: ts.Visitor = (node: ts.Node): ts.VisitResult => { - // Check if node is a statement to be dropped. - if (statements.find((st) => st.expressionStatement === node)) { - return undefined; - } - - // Check if node is a class to add statements to. - const clazz = classes.find((cl) => cl.classFunction === node); - if (clazz) { - const functionExpression: ts.FunctionExpression = node as ts.FunctionExpression; - - const newExpressions = clazz.statements.map((st) => - ts.createStatement(st.expressionStatement.expression)); - - // Create a new body with all the original statements, plus new ones, - // plus return statement. - const newBody = ts.createBlock([ - ...functionExpression.body.statements.slice(0, -1), - ...newExpressions, - ...functionExpression.body.statements.slice(-1), - ]); - - const newNode = ts.createFunctionExpression( - functionExpression.modifiers, - functionExpression.asteriskToken, - functionExpression.name, - functionExpression.typeParameters, - functionExpression.parameters, - functionExpression.type, - newBody, - ); - - // Replace node with modified one. - return ts.visitEachChild(newNode, visitor, context); - } - - // Otherwise return node as is. - return ts.visitEachChild(node, visitor, context); - }; - - return ts.visitNode(sf, visitor); - }; - - return transformer; - }; -} - -function findClassDeclarations(node: ts.Node): ClassData[] { - const classes: ClassData[] = []; - // Find all class declarations, build a ClassData for each. - ts.forEachChild(node, (child) => { - if (child.kind !== ts.SyntaxKind.VariableStatement) { - return; - } - const varStmt = child as ts.VariableStatement; - if (varStmt.declarationList.declarations.length > 1) { - return; - } - const varDecl = varStmt.declarationList.declarations[0]; - if (varDecl.name.kind !== ts.SyntaxKind.Identifier) { - return; - } - const name = (varDecl.name as ts.Identifier).text; - const expr = varDecl.initializer; - if (!expr || expr.kind !== ts.SyntaxKind.ParenthesizedExpression) { - return; - } - if ((expr as ts.ParenthesizedExpression).expression.kind !== ts.SyntaxKind.CallExpression) { - return; - } - const callExpr = (expr as ts.ParenthesizedExpression).expression as ts.CallExpression; - if (callExpr.expression.kind !== ts.SyntaxKind.FunctionExpression) { - return; - } - const fn = callExpr.expression as ts.FunctionExpression; - if (fn.body.statements.length < 2) { - return; - } - if (fn.body.statements[0].kind !== ts.SyntaxKind.FunctionDeclaration) { - return; - } - const innerFn = fn.body.statements[0] as ts.FunctionDeclaration; - if (fn.body.statements[fn.body.statements.length - 1].kind !== ts.SyntaxKind.ReturnStatement) { - return; - } - if (!innerFn.name || innerFn.name.kind !== ts.SyntaxKind.Identifier) { - return; - } - if ((innerFn.name as ts.Identifier).text !== name) { - return; - } - classes.push({ - name, - class: varDecl, - classFunction: fn, - statements: [], - }); - }); - - return classes; -} - -function findClassStaticPropertyAssignments( - node: ts.Node, - checker: ts.TypeChecker, - classes: ClassData[]): StatementData[] { - - const statements: StatementData[] = []; - - // Find each assignment outside of the declaration. - ts.forEachChild(node, (child) => { - if (child.kind !== ts.SyntaxKind.ExpressionStatement) { - return; - } - const expressionStatement = child as ts.ExpressionStatement; - if (expressionStatement.expression.kind !== ts.SyntaxKind.BinaryExpression) { - return; - } - const binEx = expressionStatement.expression as ts.BinaryExpression; - if (binEx.left.kind !== ts.SyntaxKind.PropertyAccessExpression) { - return; - } - const propAccess = binEx.left as ts.PropertyAccessExpression; - if (propAccess.expression.kind !== ts.SyntaxKind.Identifier) { - return; - } - - const symbol = checker.getSymbolAtLocation(propAccess.expression); - if (!symbol) { - return; - } - - const decls = symbol.declarations; - if (decls == undefined || decls.length === 0) { - return; - } - - const hostClass = classes.find((clazz => decls.includes(clazz.class))); - if (!hostClass) { - return; - } - const statement: StatementData = { expressionStatement, hostClass }; - - hostClass.statements.push(statement); - statements.push(statement); - }); - - return statements; -} diff --git a/packages/angular_devkit/build_optimizer/src/transforms/class-fold_spec.ts b/packages/angular_devkit/build_optimizer/src/transforms/class-fold_spec.ts deleted file mode 100644 index 4af639ca0dcc..000000000000 --- a/packages/angular_devkit/build_optimizer/src/transforms/class-fold_spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable-next-line:no-implicit-dependencies -import { tags } from '@angular-devkit/core'; -import { transformJavascript } from '../helpers/transform-javascript'; -import { getFoldFileTransformer } from './class-fold'; - - -const transform = (content: string) => transformJavascript( - { content, getTransforms: [getFoldFileTransformer], typeCheck: true }).content; - -describe('class-fold', () => { - it('folds static properties into class', () => { - const staticProperty = 'Clazz.prop = 1;'; - const input = tags.stripIndent` - var Clazz = (function () { function Clazz() { } return Clazz; }()); - ${staticProperty} - `; - const output = tags.stripIndent` - var Clazz = (function () { function Clazz() { } - ${staticProperty} return Clazz; }()); - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('folds multiple static properties into class', () => { - const staticProperty = 'Clazz.prop = 1;'; - const anotherStaticProperty = 'Clazz.anotherProp = 2;'; - const input = tags.stripIndent` - var Clazz = (function () { function Clazz() { } return Clazz; }()); - ${staticProperty} - ${anotherStaticProperty} - `; - const output = tags.stripIndent` - var Clazz = (function () { function Clazz() { } - ${staticProperty} ${anotherStaticProperty} return Clazz; }()); - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); -}); diff --git a/packages/angular_devkit/build_optimizer/src/transforms/import-tslib.ts b/packages/angular_devkit/build_optimizer/src/transforms/import-tslib.ts deleted file mode 100644 index 7251aac5e2ad..000000000000 --- a/packages/angular_devkit/build_optimizer/src/transforms/import-tslib.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * @license - * 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 - */ -import * as ts from 'typescript'; - -/** - * @deprecated From 0.9.0 - */ -export function testImportTslib(content: string) { - const regex = /var (__extends|__decorate|__metadata|__param) = \(.*\r?\n\s+(.*\r?\n)*\s*\};/; - - return regex.test(content); -} - -export function getImportTslibTransformer(): ts.TransformerFactory { - return (context: ts.TransformationContext): ts.Transformer => { - - const transformer: ts.Transformer = (sf: ts.SourceFile) => { - - const tslibImports: (ts.VariableStatement | ts.ImportDeclaration)[] = []; - - // Check if module has CJS exports. If so, use 'require()' instead of 'import'. - const useRequire = /exports.\S+\s*=/.test(sf.getText()); - - const visitor: ts.Visitor = (node: ts.Node): ts.Node | undefined => { - - // Check if node is a TS helper declaration and replace with import if yes - if (ts.isVariableStatement(node)) { - const declarations = node.declarationList.declarations; - - if (declarations.length === 1 && ts.isIdentifier(declarations[0].name)) { - const name = (declarations[0].name as ts.Identifier).text; - - if (isHelperName(name)) { - // TODO: maybe add a few more checks, like checking the first part of the assignment. - const tslibImport = createTslibImport(name, useRequire); - tslibImports.push(tslibImport); - - return undefined; - } - } - } - - return ts.visitEachChild(node, visitor, context); - }; - - const sfUpdated = ts.visitEachChild(sf, visitor, context); - - // Add tslib imports before any other statement - return tslibImports.length > 0 - ? ts.updateSourceFileNode(sfUpdated, [ - ...tslibImports, - ...sfUpdated.statements, - ]) - : sfUpdated; - }; - - return transformer; - }; -} - -function createTslibImport( - name: string, - useRequire = false, -): ts.VariableStatement | ts.ImportDeclaration { - if (useRequire) { - // Use `var __helper = /*@__PURE__*/ require("tslib").__helper`. - const requireCall = ts.createCall(ts.createIdentifier('require'), undefined, - [ts.createLiteral('tslib')]); - const pureRequireCall = ts.addSyntheticLeadingComment( - requireCall, ts.SyntaxKind.MultiLineCommentTrivia, '@__PURE__', false); - const helperAccess = ts.createPropertyAccess(pureRequireCall, name); - const variableDeclaration = ts.createVariableDeclaration(name, undefined, helperAccess); - const variableStatement = ts.createVariableStatement(undefined, [variableDeclaration]); - - return variableStatement; - } else { - // Use `import { __helper } from "tslib"`. - const namedImports = ts.createNamedImports([ts.createImportSpecifier(undefined, - ts.createIdentifier(name))]); - const importClause = ts.createImportClause(undefined, namedImports); - const newNode = ts.createImportDeclaration(undefined, undefined, importClause, - ts.createLiteral('tslib')); - - return newNode; - } -} - -function isHelperName(name: string): boolean { - // TODO: there are more helpers than these, should we replace them all? - const tsHelpers = [ - '__extends', - '__decorate', - '__metadata', - '__param', - ]; - - return tsHelpers.indexOf(name) !== -1; -} diff --git a/packages/angular_devkit/build_optimizer/src/transforms/import-tslib_spec.ts b/packages/angular_devkit/build_optimizer/src/transforms/import-tslib_spec.ts deleted file mode 100644 index 7f914f582a95..000000000000 --- a/packages/angular_devkit/build_optimizer/src/transforms/import-tslib_spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable-next-line:no-implicit-dependencies -import { tags } from '@angular-devkit/core'; -import { transformJavascript } from '../helpers/transform-javascript'; -import { getImportTslibTransformer, testImportTslib } from './import-tslib'; - - -const transform = (content: string) => transformJavascript( - { content, getTransforms: [getImportTslibTransformer] }).content; - -describe('import-tslib', () => { - it('replaces __extends', () => { - const input = tags.stripIndent` - var __extends = (this && this.__extends) || function (d, b) { - for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; - function __() { this.constructor = d; } - d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); - }; - `; - const output = tags.stripIndent` - import { __extends } from "tslib"; - `; - - expect(testImportTslib(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('replaces wrapped __extends', () => { - // tslint:disable:max-line-length - const input = tags.stripIndent` - export default function appGlobal() { - var __extends = (this && this.__extends) || function (d, b) { - for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; - function __() { this.constructor = d; } - d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); - }; - } - `; - const output = tags.stripIndent` - import { __extends } from "tslib"; - export default function appGlobal() { } - `; - - expect(testImportTslib(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('replaces __decorate', () => { - // tslint:disable:max-line-length - const input = tags.stripIndent` - var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { - var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; - if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); - else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; - return c > 3 && r && Object.defineProperty(target, key, r), r; - }; - `; - // tslint:enable:max-line-length - const output = tags.stripIndent` - import { __decorate } from "tslib"; - `; - - expect(testImportTslib(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('replaces __metadata', () => { - // tslint:disable:max-line-length - const input = tags.stripIndent` - var __metadata = (this && this.__metadata) || function (k, v) { - if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); - }; - `; - // tslint:enable:max-line-length - const output = tags.stripIndent` - import { __metadata } from "tslib"; - `; - - expect(testImportTslib(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('replaces __param', () => { - const input = tags.stripIndent` - var __param = (this && this.__param) || function (paramIndex, decorator) { - return function (target, key) { decorator(target, key, paramIndex); } - }; - `; - - const output = tags.stripIndent` - import { __param } from "tslib"; - `; - - expect(testImportTslib(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('replaces uses "require" instead of "import" on CJS modules', () => { - const input = tags.stripIndent` - var __extends = (this && this.__extends) || function (d, b) { - for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; - function __() { this.constructor = d; } - d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); - }; - exports.meaning = 42; - `; - const output = tags.stripIndent` - var __extends = /*@__PURE__*/ require("tslib").__extends; - exports.meaning = 42; - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - -}); diff --git a/packages/angular_devkit/build_optimizer/src/transforms/prefix-classes.ts b/packages/angular_devkit/build_optimizer/src/transforms/prefix-classes.ts deleted file mode 100644 index 3535cbf7cfaf..000000000000 --- a/packages/angular_devkit/build_optimizer/src/transforms/prefix-classes.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** - * @license - * 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 - */ -import * as ts from 'typescript'; - -/** - * @deprecated From 0.9.0 - */ -export function testPrefixClasses(content: string) { - const exportVarSetter = /(?:export )?(?:var|const)\s+(\S+)\s*=\s*/; - const multiLineComment = /\s*(?:\/\*[\s\S]*?\*\/)?\s*/; - const newLine = /\s*\r?\n\s*/; - - const regexes = [ - [ - /^/, - exportVarSetter, multiLineComment, - /\(/, multiLineComment, - /\s*function \(\) {/, newLine, - multiLineComment, - /function \1\([^\)]*\) \{/, newLine, - ], - [ - /^/, - exportVarSetter, multiLineComment, - /\(/, multiLineComment, - /\s*function \(_super\) {/, newLine, - /\w*\.?__extends\(\w+, _super\);/, - ], - ].map(arr => new RegExp(arr.map(x => x.source).join(''), 'm')); - - return regexes.some((regex) => regex.test(content)); -} - -const superParameterName = '_super'; -const extendsHelperName = '__extends'; - -export function getPrefixClassesTransformer(): ts.TransformerFactory { - return (context: ts.TransformationContext): ts.Transformer => { - const transformer: ts.Transformer = (sf: ts.SourceFile) => { - - const pureFunctionComment = '@__PURE__'; - - const visitor: ts.Visitor = (node: ts.Node): ts.VisitResult => { - - // Add pure comment to downleveled classes. - if (ts.isVariableStatement(node) && isDownleveledClass(node)) { - const varDecl = node.declarationList.declarations[0]; - const varInitializer = varDecl.initializer as ts.Expression; - - // Update node with the pure comment before the variable declaration initializer. - const newNode = ts.updateVariableStatement( - node, - node.modifiers, - ts.updateVariableDeclarationList( - node.declarationList, - [ - ts.updateVariableDeclaration( - varDecl, - varDecl.name, - varDecl.type, - ts.addSyntheticLeadingComment( - varInitializer, - ts.SyntaxKind.MultiLineCommentTrivia, - pureFunctionComment, - false, - ), - ), - ], - ), - ); - - // Replace node with modified one. - return ts.visitEachChild(newNode, visitor, context); - } - - // Otherwise return node as is. - return ts.visitEachChild(node, visitor, context); - }; - - return ts.visitEachChild(sf, visitor, context); - }; - - return transformer; - }; -} - -// Determine if a node matched the structure of a downleveled TS class. -function isDownleveledClass(node: ts.Node): boolean { - - if (!ts.isVariableStatement(node)) { - return false; - } - - if (node.declarationList.declarations.length !== 1) { - return false; - } - - const variableDeclaration = node.declarationList.declarations[0]; - - if (!ts.isIdentifier(variableDeclaration.name) - || !variableDeclaration.initializer) { - return false; - } - - let potentialClass = variableDeclaration.initializer; - - // TS 2.3 has an unwrapped class IIFE - // TS 2.4 uses a function expression wrapper - // TS 2.5 uses an arrow function wrapper - if (ts.isParenthesizedExpression(potentialClass)) { - potentialClass = potentialClass.expression; - } - - if (!ts.isCallExpression(potentialClass) || potentialClass.arguments.length > 1) { - return false; - } - - let wrapperBody: ts.Block; - if (ts.isFunctionExpression(potentialClass.expression)) { - wrapperBody = potentialClass.expression.body; - } else if (ts.isArrowFunction(potentialClass.expression) - && ts.isBlock(potentialClass.expression.body)) { - wrapperBody = potentialClass.expression.body; - } else { - return false; - } - - if (wrapperBody.statements.length === 0) { - return false; - } - - const functionExpression = potentialClass.expression; - const functionStatements = wrapperBody.statements; - - // need a minimum of two for a function declaration and return statement - if (functionStatements.length < 2) { - return false; - } - - // The variable name should be the class name. - const className = variableDeclaration.name.text; - - const firstStatement = functionStatements[0]; - - // find return statement - may not be last statement - let returnStatement: ts.ReturnStatement | undefined; - for (let i = functionStatements.length - 1; i > 0; i--) { - if (ts.isReturnStatement(functionStatements[i])) { - returnStatement = functionStatements[i] as ts.ReturnStatement; - break; - } - } - - if (returnStatement == undefined - || returnStatement.expression == undefined - || !ts.isIdentifier(returnStatement.expression)) { - return false; - } - - if (functionExpression.parameters.length === 0) { - // potential non-extended class or wrapped es2015 class - return (ts.isFunctionDeclaration(firstStatement) || ts.isClassDeclaration(firstStatement)) - && firstStatement.name !== undefined - && firstStatement.name.text === className - && returnStatement.expression.text === firstStatement.name.text; - } else if (functionExpression.parameters.length !== 1) { - return false; - } - - // Potential extended class - - const functionParameter = functionExpression.parameters[0]; - - if (!ts.isIdentifier(functionParameter.name) - || functionParameter.name.text !== superParameterName) { - return false; - } - - if (functionStatements.length < 3 || !ts.isExpressionStatement(firstStatement)) { - return false; - } - - if (!ts.isCallExpression(firstStatement.expression)) { - return false; - } - - const extendCallExpression = firstStatement.expression; - - let functionName; - if (ts.isIdentifier(extendCallExpression.expression)) { - functionName = extendCallExpression.expression.text; - } else if (ts.isPropertyAccessExpression(extendCallExpression.expression)) { - functionName = extendCallExpression.expression.name.text; - } - - if (!functionName || !functionName.endsWith(extendsHelperName)) { - return false; - } - - if (extendCallExpression.arguments.length === 0) { - return false; - } - - const lastArgument = extendCallExpression.arguments[extendCallExpression.arguments.length - 1]; - - if (!ts.isIdentifier(lastArgument) || lastArgument.text !== functionParameter.name.text) { - return false; - } - - const secondStatement = functionStatements[1]; - - return ts.isFunctionDeclaration(secondStatement) - && secondStatement.name !== undefined - && className.endsWith(secondStatement.name.text) - && returnStatement.expression.text === secondStatement.name.text; -} diff --git a/packages/angular_devkit/build_optimizer/src/transforms/prefix-classes_spec.ts b/packages/angular_devkit/build_optimizer/src/transforms/prefix-classes_spec.ts deleted file mode 100644 index 211b0175aa4c..000000000000 --- a/packages/angular_devkit/build_optimizer/src/transforms/prefix-classes_spec.ts +++ /dev/null @@ -1,307 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable:no-big-function -// tslint:disable-next-line:no-implicit-dependencies -import { tags } from '@angular-devkit/core'; -import { transformJavascript } from '../helpers/transform-javascript'; -import { getPrefixClassesTransformer, testPrefixClasses } from './prefix-classes'; - - -const transform = (content: string) => transformJavascript( - { content, getTransforms: [getPrefixClassesTransformer] }).content; - -describe('prefix-classes', () => { - it('prefix TS 2.0 - 2.4 downlevel class', () => { - const input = tags.stripIndent` - var BasicTestCase = (function () { - function BasicTestCase() { - } - return BasicTestCase; - }()); - `; - const output = tags.stripIndent` - var BasicTestCase = /*@__PURE__*/ (function () { - function BasicTestCase() { - } - return BasicTestCase; - }()); - `; - - expect(testPrefixClasses(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - // NOTE: TS 2.1+ uses a standalone export after the variable statement - it('prefix TS 2.0 exported downlevel class with ES2015 modules', () => { - const input = tags.stripIndent` - export var OuterSubscriber = (function (_super) { - __extends(OuterSubscriber, _super); - function OuterSubscriber() { - _super.apply(this, arguments); - } - return OuterSubscriber; - }()); - `; - const output = tags.stripIndent` - export var OuterSubscriber = /*@__PURE__*/ (function (_super) { - __extends(OuterSubscriber, _super); - function OuterSubscriber() { - _super.apply(this, arguments); - } - return OuterSubscriber; - }()); - `; - - expect(testPrefixClasses(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('prefix TS 2.0 downlevel class with extends', () => { - const input = tags.stripIndent` - var ExtendedClass = (function (_super) { - __extends(ExtendedClass, _super); - function ExtendedClass() { - _super.apply(this, arguments); - } - return ExtendedClass; - }(StaticTestCase)); - `; - const output = tags.stripIndent` - var ExtendedClass = /*@__PURE__*/ (function (_super) { - __extends(ExtendedClass, _super); - function ExtendedClass() { - _super.apply(this, arguments); - } - return ExtendedClass; - }(StaticTestCase)); - `; - - expect(testPrefixClasses(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('prefix TS 2.1 - 2.3 downlevel class with static variable', () => { - const input = tags.stripIndent` - var StaticTestCase = (function () { - function StaticTestCase() { - } - return StaticTestCase; - }()); - StaticTestCase.StaticTest = true; - `; - const output = tags.stripIndent` - var StaticTestCase = /*@__PURE__*/ (function () { - function StaticTestCase() { - } - return StaticTestCase; - }()); - StaticTestCase.StaticTest = true; - `; - - expect(testPrefixClasses(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('prefix TS 2.1 - 2.4 downlevel class with extends', () => { - const input = tags.stripIndent` - var ExtendedClass = (function (_super) { - __extends(ExtendedClass, _super); - function ExtendedClass() { - return _super !== null && _super.apply(this, arguments) || this; - } - return ExtendedClass; - }(StaticTestCase)); - `; - const output = tags.stripIndent` - var ExtendedClass = /*@__PURE__*/ (function (_super) { - __extends(ExtendedClass, _super); - function ExtendedClass() { - return _super !== null && _super.apply(this, arguments) || this; - } - return ExtendedClass; - }(StaticTestCase)); - `; - - expect(testPrefixClasses(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('prefix TS 2.0 & 2.4 downlevel class with static variable', () => { - const input = tags.stripIndent` - var StaticTestCase = (function () { - function StaticTestCase() { - } - StaticTestCase.StaticTest = true; - return StaticTestCase; - }()); - `; - const output = tags.stripIndent` - var StaticTestCase = /*@__PURE__*/ (function () { - function StaticTestCase() { - } - StaticTestCase.StaticTest = true; - return StaticTestCase; - }()); - `; - - expect(testPrefixClasses(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('prefix TS 2.5 - 2.6 downlevel class', () => { - const input = tags.stripIndent` - var BasicTestCase = /** @class */ (function () { - function BasicTestCase() { - } - return BasicTestCase; - }()); - `; - const output = tags.stripIndent` - var BasicTestCase = /** @class */ /*@__PURE__*/ (function () { - function BasicTestCase() { - } - return BasicTestCase; - }()); - `; - - expect(testPrefixClasses(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('prefix TS 2.5 - 2.6 downlevel class with static variable', () => { - const input = tags.stripIndent` - var StaticTestCase = /** @class */ (function () { - function StaticTestCase() { - } - StaticTestCase.StaticTest = true; - return StaticTestCase; - }()); - `; - const output = tags.stripIndent` - var StaticTestCase = /** @class */ /*@__PURE__*/ (function () { - function StaticTestCase() { - } - StaticTestCase.StaticTest = true; - return StaticTestCase; - }()); - `; - - expect(testPrefixClasses(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('prefix TS 2.5 - 2.6 downlevel class with extends', () => { - const input = tags.stripIndent` - var ExtendedClass = /** @class */ (function (_super) { - __extends(ExtendedClass, _super); - function ExtendedClass() { - return _super !== null && _super.apply(this, arguments) || this; - } - return ExtendedClass; - }(StaticTestCase)); - `; - const output = tags.stripIndent` - var ExtendedClass = /** @class */ /*@__PURE__*/ (function (_super) { - __extends(ExtendedClass, _super); - function ExtendedClass() { - return _super !== null && _super.apply(this, arguments) || this; - } - return ExtendedClass; - }(StaticTestCase)); - `; - - expect(testPrefixClasses(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('works with tslib namespace import', () => { - const input = tags.stripIndent` - var BufferSubscriber = /** @class */ (function (_super) { - tslib_1.__extends(BufferSubscriber, _super); - function BufferSubscriber() { - return _super !== null && _super.apply(this, arguments) || this; - } - return BufferSubscriber; - }(OuterSubscriber)); - `; - const output = tags.stripIndent` - var BufferSubscriber = /** @class */ /*@__PURE__*/ (function (_super) { - tslib_1.__extends(BufferSubscriber, _super); - function BufferSubscriber() { - return _super !== null && _super.apply(this, arguments) || this; - } - return BufferSubscriber; - }(OuterSubscriber)); - `; - - expect(testPrefixClasses(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('fixes the RxJS use case (issue #214)', () => { - const input = ` - var ExtendedClass = /*@__PURE__*/ (/*@__PURE__*/ function (_super) { - __extends(ExtendedClass, _super); - function ExtendedClass() { - return _super !== null && _super.apply(this, arguments) || this; - } - return ExtendedClass; - }(StaticTestCase)); - - /** - * We need this JSDoc comment for affecting ESDoc. - * @ignore - * @extends {Ignored} - */ - var zip_ZipSubscriber = /*@__PURE__*/ (/*@__PURE__*/ function (_super) { - zip___extends(ZipSubscriber, _super); - function ZipSubscriber(destination, project, values) { - if (values === void 0) { - values = Object.create(null); - } - _super.call(this, destination); - this.iterators = []; - this.active = 0; - this.project = (typeof project === 'function') ? project : null; - this.values = values; - } - return ZipSubscriber; - }(Subscriber)); - `; - const output = ` - var ExtendedClass = /*@__PURE__*/ /*@__PURE__*/ ( /*@__PURE__*/function (_super) { - __extends(ExtendedClass, _super); - function ExtendedClass() { - return _super !== null && _super.apply(this, arguments) || this; - } - return ExtendedClass; - }(StaticTestCase)); - - /** - * We need this JSDoc comment for affecting ESDoc. - * @ignore - * @extends {Ignored} - */ - var zip_ZipSubscriber = /*@__PURE__*/ /*@__PURE__*/ ( /*@__PURE__*/function (_super) { - zip___extends(ZipSubscriber, _super); - function ZipSubscriber(destination, project, values) { - if (values === void 0) { - values = Object.create(null); - } - _super.call(this, destination); - this.iterators = []; - this.active = 0; - this.project = (typeof project === 'function') ? project : null; - this.values = values; - } - return ZipSubscriber; - }(Subscriber)); - `; - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); -}); diff --git a/packages/angular_devkit/build_optimizer/src/transforms/prefix-functions.ts b/packages/angular_devkit/build_optimizer/src/transforms/prefix-functions.ts deleted file mode 100644 index 99d6227b65a5..000000000000 --- a/packages/angular_devkit/build_optimizer/src/transforms/prefix-functions.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * @license - * 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 - */ -import * as ts from 'typescript'; - - -const pureFunctionComment = '@__PURE__'; - -export function getPrefixFunctionsTransformer(): ts.TransformerFactory { - return (context: ts.TransformationContext): ts.Transformer => { - const transformer: ts.Transformer = (sf: ts.SourceFile) => { - - const topLevelFunctions = findTopLevelFunctions(sf); - - const visitor: ts.Visitor = (node: ts.Node): ts.Node => { - // Add pure function comment to top level functions. - if (topLevelFunctions.has(node)) { - const newNode = ts.addSyntheticLeadingComment( - node, ts.SyntaxKind.MultiLineCommentTrivia, pureFunctionComment, false); - - // Replace node with modified one. - return ts.visitEachChild(newNode, visitor, context); - } - - // Otherwise return node as is. - return ts.visitEachChild(node, visitor, context); - }; - - return ts.visitNode(sf, visitor); - }; - - return transformer; - }; -} - -export function findTopLevelFunctions(parentNode: ts.Node): Set { - const topLevelFunctions = new Set(); - - function cb(node: ts.Node) { - // Stop recursing into this branch if it's a definition construct. - // These are function expression, function declaration, class, or arrow function (lambda). - // The body of these constructs will not execute when loading the module, so we don't - // need to mark function calls inside them as pure. - // Class static initializers in ES2015 are an exception we don't cover. They would need similar - // processing as enums to prevent property setting from causing the class to be retained. - if (ts.isFunctionDeclaration(node) - || ts.isFunctionExpression(node) - || ts.isClassDeclaration(node) - || ts.isArrowFunction(node) - || ts.isMethodDeclaration(node) - ) { - return; - } - - let noPureComment = !hasPureComment(node); - let innerNode = node; - while (innerNode && ts.isParenthesizedExpression(innerNode)) { - innerNode = innerNode.expression; - noPureComment = noPureComment && !hasPureComment(innerNode); - } - - if (!innerNode) { - return; - } - - if (noPureComment) { - if (ts.isNewExpression(innerNode)) { - topLevelFunctions.add(node); - } else if (ts.isCallExpression(innerNode)) { - let expression: ts.Expression = innerNode.expression; - while (expression && ts.isParenthesizedExpression(expression)) { - expression = expression.expression; - } - if (expression) { - if (ts.isFunctionExpression(expression)) { - // Skip IIFE's with arguments - // This could be improved to check if there are any references to variables - if (innerNode.arguments.length === 0) { - topLevelFunctions.add(node); - } - } else { - topLevelFunctions.add(node); - } - } - } - } - - ts.forEachChild(innerNode, cb); - } - - ts.forEachChild(parentNode, cb); - - return topLevelFunctions; -} - -function hasPureComment(node: ts.Node) { - if (!node) { - return false; - } - const leadingComment = ts.getSyntheticLeadingComments(node); - - return leadingComment && leadingComment.some((comment) => comment.text === pureFunctionComment); -} diff --git a/packages/angular_devkit/build_optimizer/src/transforms/prefix-functions_spec.ts b/packages/angular_devkit/build_optimizer/src/transforms/prefix-functions_spec.ts deleted file mode 100644 index 459eb69fda8c..000000000000 --- a/packages/angular_devkit/build_optimizer/src/transforms/prefix-functions_spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable-next-line:no-implicit-dependencies -import { tags } from '@angular-devkit/core'; -import { transformJavascript } from '../helpers/transform-javascript'; -import { getPrefixFunctionsTransformer } from './prefix-functions'; - - -const transform = (content: string) => transformJavascript( - { content, getTransforms: [getPrefixFunctionsTransformer] }).content; - -describe('prefix-functions', () => { - const clazz = 'var Clazz = (function () { function Clazz() { } return Clazz; }());'; - - describe('pure functions', () => { - it('adds comment to new calls', () => { - const input = tags.stripIndent` - var newClazz = new Clazz(); - `; - const output = tags.stripIndent` - var newClazz = /*@__PURE__*/ new Clazz(); - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('adds comment to function calls', () => { - const input = tags.stripIndent` - var newClazz = Clazz(); - `; - const output = tags.stripIndent` - var newClazz = /*@__PURE__*/ Clazz(); - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('adds comment outside of IIFEs', () => { - const input = tags.stripIndent` - ${clazz} - var ClazzTwo = (function () { function Clazz() { } return Clazz; })(); - `; - const output = tags.stripIndent` - var Clazz = /*@__PURE__*/ (function () { function Clazz() { } return Clazz; }()); - var ClazzTwo = /*@__PURE__*/ (function () { function Clazz() { } return Clazz; })(); - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('doesn\'t add comment when inside function declarations or expressions', () => { - const input = tags.stripIndent` - function funcDecl() { - var newClazz = Clazz(); - var newClazzTwo = new Clazz(); - } - - var funcExpr = function () { - var newClazz = Clazz(); - var newClazzTwo = new Clazz(); - }; - `; - const output = tags.stripIndent` - ${input} - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('doesn\'t add comment to downlevel namespaces', () => { - const input = tags.stripIndent` - function MyFunction() { } - - (function (MyFunction) { - function subFunction() { } - MyFunction.subFunction = subFunction; - })(MyFunction || (MyFunctionn = {})); - - export { MyFunction }; - `; - const output = tags.stripIndent` - ${input} - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('doesn\'t add comment when inside class', () => { - const input = tags.stripIndent` - class Foo { - constructor(e) { - super(e); - } - method() { - var newClazz = new Clazz(); - } - } - `; - const output = tags.stripIndent` - ${input} - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('doesn\'t add comment when inside arrow function', () => { - const input = tags.stripIndent` - export const subscribeToArray = (array) => (subscriber) => { - for (let i = 0, len = array.length; i < len && !subscriber.closed; i++) { - subscriber.next(array[i]); - } - if (!subscriber.closed) { - subscriber.complete(); - } - }; - `; - const output = tags.stripIndent` - ${input} - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('doesn\'t add comment when inside object literal method', () => { - const input = tags.stripIndent` - const literal = { - method() { - var newClazz = new Clazz(); - } - }; - `; - const output = tags.stripIndent` - ${input} - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - }); -}); diff --git a/packages/angular_devkit/build_optimizer/src/transforms/scrub-file.ts b/packages/angular_devkit/build_optimizer/src/transforms/scrub-file.ts deleted file mode 100644 index 2f89eb1336d4..000000000000 --- a/packages/angular_devkit/build_optimizer/src/transforms/scrub-file.ts +++ /dev/null @@ -1,635 +0,0 @@ -/** - * @license - * 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 - */ -import * as ts from 'typescript'; -import { collectDeepNodes } from '../helpers/ast-utils'; - -/** - * @deprecated From 0.9.0 - */ -export function testScrubFile(content: string) { - const markers = [ - 'decorators', - '__decorate', - 'propDecorators', - 'ctorParameters', - ]; - - return markers.some((marker) => content.indexOf(marker) !== -1); -} - -// Don't remove `ctorParameters` from these. -const platformWhitelist = [ - 'PlatformRef_', - 'TestabilityRegistry', - 'Console', - 'BrowserPlatformLocation', -]; - -const angularSpecifiers = [ - // Class level decorators. - 'Component', - 'Directive', - 'Injectable', - 'NgModule', - 'Pipe', - - // Property level decorators. - 'ContentChild', - 'ContentChildren', - 'HostBinding', - 'HostListener', - 'Input', - 'Output', - 'ViewChild', - 'ViewChildren', -]; - -export function getScrubFileTransformer(program: ts.Program): ts.TransformerFactory { - const checker = program.getTypeChecker(); - - return (context: ts.TransformationContext): ts.Transformer => { - - const transformer: ts.Transformer = (sf: ts.SourceFile) => { - - const ngMetadata = findAngularMetadata(sf); - const tslibImports = findTslibImports(sf); - - const nodes: ts.Node[] = []; - ts.forEachChild(sf, checkNodeForDecorators); - - function checkNodeForDecorators(node: ts.Node): void { - if (node.kind !== ts.SyntaxKind.ExpressionStatement) { - // TS 2.4 nests decorators inside downleveled class IIFEs, so we - // must recurse into them to find the relevant expression statements. - return ts.forEachChild(node, checkNodeForDecorators); - } - const exprStmt = node as ts.ExpressionStatement; - if (isDecoratorAssignmentExpression(exprStmt)) { - nodes.push(...pickDecorationNodesToRemove(exprStmt, ngMetadata, checker)); - } - if (isDecorateAssignmentExpression(exprStmt, tslibImports, checker)) { - nodes.push(...pickDecorateNodesToRemove(exprStmt, tslibImports, ngMetadata, checker)); - } - if (isAngularDecoratorMetadataExpression(exprStmt, ngMetadata, tslibImports, checker)) { - nodes.push(node); - } - if (isPropDecoratorAssignmentExpression(exprStmt)) { - nodes.push(...pickPropDecorationNodesToRemove(exprStmt, ngMetadata, checker)); - } - if (isCtorParamsAssignmentExpression(exprStmt) - && !isCtorParamsWhitelistedService(exprStmt)) { - nodes.push(node); - } - } - - const visitor: ts.Visitor = (node: ts.Node): ts.VisitResult => { - // Check if node is a statement to be dropped. - if (nodes.find((n) => n === node)) { - return undefined; - } - - // Otherwise return node as is. - return ts.visitEachChild(node, visitor, context); - }; - - return ts.visitNode(sf, visitor); - }; - - return transformer; - }; -} - -export function expect(node: ts.Node, kind: ts.SyntaxKind): T { - if (node.kind !== kind) { - throw new Error('Invalid node type.'); - } - - return node as T; -} - -function nameOfSpecifier(node: ts.ImportSpecifier): string { - return node.name && node.name.text || ''; -} - -function findAngularMetadata(node: ts.Node): ts.Node[] { - let specs: ts.Node[] = []; - ts.forEachChild(node, (child) => { - if (child.kind === ts.SyntaxKind.ImportDeclaration) { - const importDecl = child as ts.ImportDeclaration; - if (isAngularCoreImport(importDecl)) { - specs.push(...collectDeepNodes(node, ts.SyntaxKind.ImportSpecifier) - .filter((spec) => isAngularCoreSpecifier(spec))); - } - } - }); - - const localDecl = findAllDeclarations(node) - .filter((decl) => angularSpecifiers.indexOf((decl.name as ts.Identifier).text) !== -1); - if (localDecl.length === angularSpecifiers.length) { - specs = specs.concat(localDecl); - } - - return specs; -} - -function findAllDeclarations(node: ts.Node): ts.VariableDeclaration[] { - const nodes: ts.VariableDeclaration[] = []; - ts.forEachChild(node, (child) => { - if (child.kind === ts.SyntaxKind.VariableStatement) { - const vStmt = child as ts.VariableStatement; - vStmt.declarationList.declarations.forEach((decl) => { - if (decl.name.kind !== ts.SyntaxKind.Identifier) { - return; - } - nodes.push(decl); - }); - } - }); - - return nodes; -} - -function isAngularCoreImport(node: ts.ImportDeclaration): boolean { - return true && - node.moduleSpecifier && - node.moduleSpecifier.kind === ts.SyntaxKind.StringLiteral && - (node.moduleSpecifier as ts.StringLiteral).text === '@angular/core'; -} - -function isAngularCoreSpecifier(node: ts.ImportSpecifier): boolean { - return angularSpecifiers.indexOf(nameOfSpecifier(node)) !== -1; -} - -// Check if assignment is `Clazz.decorators = [...];`. -function isDecoratorAssignmentExpression(exprStmt: ts.ExpressionStatement): boolean { - if (exprStmt.expression.kind !== ts.SyntaxKind.BinaryExpression) { - return false; - } - const expr = exprStmt.expression as ts.BinaryExpression; - if (expr.left.kind !== ts.SyntaxKind.PropertyAccessExpression) { - return false; - } - const propAccess = expr.left as ts.PropertyAccessExpression; - if (propAccess.expression.kind !== ts.SyntaxKind.Identifier) { - return false; - } - if (propAccess.name.text !== 'decorators') { - return false; - } - if (expr.operatorToken.kind !== ts.SyntaxKind.FirstAssignment) { - return false; - } - if (expr.right.kind !== ts.SyntaxKind.ArrayLiteralExpression) { - return false; - } - - return true; -} - -// Check if assignment is `Clazz = __decorate([...], Clazz)`. -function isDecorateAssignmentExpression( - exprStmt: ts.ExpressionStatement, - tslibImports: ts.NamespaceImport[], - checker: ts.TypeChecker, -): boolean { - - if (exprStmt.expression.kind !== ts.SyntaxKind.BinaryExpression) { - return false; - } - const expr = exprStmt.expression as ts.BinaryExpression; - if (expr.left.kind !== ts.SyntaxKind.Identifier) { - return false; - } - const classIdent = expr.left as ts.Identifier; - let callExpr: ts.CallExpression; - - if (expr.right.kind === ts.SyntaxKind.CallExpression) { - callExpr = expr.right as ts.CallExpression; - } else if (expr.right.kind === ts.SyntaxKind.BinaryExpression) { - // `Clazz = Clazz_1 = __decorate([...], Clazz)` can be found when there are static property - // accesses. - const innerExpr = expr.right as ts.BinaryExpression; - if (innerExpr.left.kind !== ts.SyntaxKind.Identifier - || innerExpr.right.kind !== ts.SyntaxKind.CallExpression) { - return false; - } - callExpr = innerExpr.right as ts.CallExpression; - } else { - return false; - } - - if (!isTslibHelper(callExpr, '__decorate', tslibImports, checker)) { - return false; - } - - if (callExpr.arguments.length !== 2) { - return false; - } - if (callExpr.arguments[1].kind !== ts.SyntaxKind.Identifier) { - return false; - } - const classArg = callExpr.arguments[1] as ts.Identifier; - if (classIdent.text !== classArg.text) { - return false; - } - if (callExpr.arguments[0].kind !== ts.SyntaxKind.ArrayLiteralExpression) { - return false; - } - - return true; -} - -// Check if expression is `__decorate([smt, __metadata("design:type", Object)], ...)`. -function isAngularDecoratorMetadataExpression( - exprStmt: ts.ExpressionStatement, - ngMetadata: ts.Node[], - tslibImports: ts.NamespaceImport[], - checker: ts.TypeChecker, -): boolean { - - if (exprStmt.expression.kind !== ts.SyntaxKind.CallExpression) { - return false; - } - const callExpr = exprStmt.expression as ts.CallExpression; - if (!isTslibHelper(callExpr, '__decorate', tslibImports, checker)) { - return false; - } - if (callExpr.arguments.length !== 4) { - return false; - } - if (callExpr.arguments[0].kind !== ts.SyntaxKind.ArrayLiteralExpression) { - return false; - } - const decorateArray = callExpr.arguments[0] as ts.ArrayLiteralExpression; - // Check first array entry for Angular decorators. - if (decorateArray.elements[0].kind !== ts.SyntaxKind.CallExpression) { - return false; - } - const decoratorCall = decorateArray.elements[0] as ts.CallExpression; - if (decoratorCall.expression.kind !== ts.SyntaxKind.Identifier) { - return false; - } - const decoratorId = decoratorCall.expression as ts.Identifier; - if (!identifierIsMetadata(decoratorId, ngMetadata, checker)) { - return false; - } - // Check second array entry for __metadata call. - if (decorateArray.elements[1].kind !== ts.SyntaxKind.CallExpression) { - return false; - } - const metadataCall = decorateArray.elements[1] as ts.CallExpression; - if (!isTslibHelper(metadataCall, '__metadata', tslibImports, checker)) { - return false; - } - - return true; -} - -// Check if assignment is `Clazz.propDecorators = [...];`. -function isPropDecoratorAssignmentExpression(exprStmt: ts.ExpressionStatement): boolean { - if (exprStmt.expression.kind !== ts.SyntaxKind.BinaryExpression) { - return false; - } - const expr = exprStmt.expression as ts.BinaryExpression; - if (expr.left.kind !== ts.SyntaxKind.PropertyAccessExpression) { - return false; - } - const propAccess = expr.left as ts.PropertyAccessExpression; - if (propAccess.expression.kind !== ts.SyntaxKind.Identifier) { - return false; - } - if (propAccess.name.text !== 'propDecorators') { - return false; - } - if (expr.operatorToken.kind !== ts.SyntaxKind.FirstAssignment) { - return false; - } - if (expr.right.kind !== ts.SyntaxKind.ObjectLiteralExpression) { - return false; - } - - return true; -} - -// Check if assignment is `Clazz.ctorParameters = [...];`. -function isCtorParamsAssignmentExpression(exprStmt: ts.ExpressionStatement): boolean { - if (exprStmt.expression.kind !== ts.SyntaxKind.BinaryExpression) { - return false; - } - const expr = exprStmt.expression as ts.BinaryExpression; - if (expr.left.kind !== ts.SyntaxKind.PropertyAccessExpression) { - return false; - } - const propAccess = expr.left as ts.PropertyAccessExpression; - if (propAccess.name.text !== 'ctorParameters') { - return false; - } - if (propAccess.expression.kind !== ts.SyntaxKind.Identifier) { - return false; - } - if (expr.operatorToken.kind !== ts.SyntaxKind.FirstAssignment) { - return false; - } - if (expr.right.kind !== ts.SyntaxKind.FunctionExpression - && expr.right.kind !== ts.SyntaxKind.ArrowFunction - ) { - return false; - } - - return true; -} - -function isCtorParamsWhitelistedService(exprStmt: ts.ExpressionStatement): boolean { - const expr = exprStmt.expression as ts.BinaryExpression; - const propAccess = expr.left as ts.PropertyAccessExpression; - const serviceId = propAccess.expression as ts.Identifier; - - return platformWhitelist.indexOf(serviceId.text) !== -1; -} - -// Remove Angular decorators from`Clazz.decorators = [...];`, or expression itself if all are -// removed. -function pickDecorationNodesToRemove( - exprStmt: ts.ExpressionStatement, - ngMetadata: ts.Node[], - checker: ts.TypeChecker, -): ts.Node[] { - - const expr = expect(exprStmt.expression, ts.SyntaxKind.BinaryExpression); - const literal = expect(expr.right, - ts.SyntaxKind.ArrayLiteralExpression); - if (!literal.elements.every((elem) => elem.kind === ts.SyntaxKind.ObjectLiteralExpression)) { - return []; - } - const elements = literal.elements as ts.NodeArray; - const ngDecorators = elements.filter((elem) => isAngularDecorator(elem, ngMetadata, checker)); - - return (elements.length > ngDecorators.length) ? ngDecorators : [exprStmt]; -} - -// Remove Angular decorators from `Clazz = __decorate([...], Clazz)`, or expression itself if all -// are removed. -function pickDecorateNodesToRemove( - exprStmt: ts.ExpressionStatement, - tslibImports: ts.NamespaceImport[], - ngMetadata: ts.Node[], - checker: ts.TypeChecker, -): ts.Node[] { - - const expr = expect(exprStmt.expression, ts.SyntaxKind.BinaryExpression); - const classId = expect(expr.left, ts.SyntaxKind.Identifier); - let callExpr: ts.CallExpression; - - if (expr.right.kind === ts.SyntaxKind.CallExpression) { - callExpr = expect(expr.right, ts.SyntaxKind.CallExpression); - } else if (expr.right.kind === ts.SyntaxKind.BinaryExpression) { - const innerExpr = expr.right as ts.BinaryExpression; - callExpr = expect(innerExpr.right, ts.SyntaxKind.CallExpression); - } else { - return []; - } - - const arrLiteral = expect(callExpr.arguments[0], - ts.SyntaxKind.ArrayLiteralExpression); - - if (!arrLiteral.elements.every((elem) => elem.kind === ts.SyntaxKind.CallExpression)) { - return []; - } - const elements = arrLiteral.elements as ts.NodeArray; - const ngDecoratorCalls = elements.filter((el) => { - if (el.expression.kind !== ts.SyntaxKind.Identifier) { - return false; - } - const id = el.expression as ts.Identifier; - - return identifierIsMetadata(id, ngMetadata, checker); - }); - - // Only remove constructor parameter metadata on non-whitelisted classes. - if (platformWhitelist.indexOf(classId.text) === -1) { - // Remove __metadata calls of type 'design:paramtypes'. - const metadataCalls = elements.filter((el) => { - if (!isTslibHelper(el, '__metadata', tslibImports, checker)) { - return false; - } - if (el.arguments.length < 2) { - return false; - } - if (el.arguments[0].kind !== ts.SyntaxKind.StringLiteral) { - return false; - } - const metadataTypeId = el.arguments[0] as ts.StringLiteral; - if (metadataTypeId.text !== 'design:paramtypes') { - return false; - } - - return true; - }); - // Remove all __param calls. - const paramCalls = elements.filter((el) => { - if (!isTslibHelper(el, '__param', tslibImports, checker)) { - return false; - } - if (el.arguments.length != 2) { - return false; - } - if (el.arguments[0].kind !== ts.SyntaxKind.NumericLiteral) { - return false; - } - - return true; - }); - ngDecoratorCalls.push(...metadataCalls, ...paramCalls); - } - - // If all decorators are metadata decorators then return the whole `Class = __decorate([...])'` - // statement so that it is removed in entirety - return (elements.length === ngDecoratorCalls.length) ? [exprStmt] : ngDecoratorCalls; -} - -// Remove Angular decorators from`Clazz.propDecorators = [...];`, or expression itself if all -// are removed. -function pickPropDecorationNodesToRemove( - exprStmt: ts.ExpressionStatement, - ngMetadata: ts.Node[], - checker: ts.TypeChecker, -): ts.Node[] { - - const expr = expect(exprStmt.expression, ts.SyntaxKind.BinaryExpression); - const literal = expect(expr.right, - ts.SyntaxKind.ObjectLiteralExpression); - if (!literal.properties.every((elem) => elem.kind === ts.SyntaxKind.PropertyAssignment && - (elem as ts.PropertyAssignment).initializer.kind === ts.SyntaxKind.ArrayLiteralExpression)) { - return []; - } - const assignments = literal.properties as ts.NodeArray; - // Consider each assignment individually. Either the whole assignment will be removed or - // a particular decorator within will. - const toRemove = assignments - .map((assign) => { - const decorators = - expect(assign.initializer, - ts.SyntaxKind.ArrayLiteralExpression).elements; - if (!decorators.every((el) => el.kind === ts.SyntaxKind.ObjectLiteralExpression)) { - return []; - } - const decsToRemove = decorators.filter((expression) => { - const lit = expect(expression, - ts.SyntaxKind.ObjectLiteralExpression); - - return isAngularDecorator(lit, ngMetadata, checker); - }); - if (decsToRemove.length === decorators.length) { - return [assign]; - } - - return decsToRemove; - }) - .reduce((accum, toRm) => accum.concat(toRm), [] as ts.Node[]); - // If every node to be removed is a property assignment (full property's decorators) and - // all properties are accounted for, remove the whole assignment. Otherwise, remove the - // nodes which were marked as safe. - if (toRemove.length === assignments.length && - toRemove.every((node) => node.kind === ts.SyntaxKind.PropertyAssignment)) { - return [exprStmt]; - } - - return toRemove; -} - -function isAngularDecorator( - literal: ts.ObjectLiteralExpression, - ngMetadata: ts.Node[], - checker: ts.TypeChecker, -): boolean { - - const types = literal.properties.filter(isTypeProperty); - if (types.length !== 1) { - return false; - } - const assign = expect(types[0], ts.SyntaxKind.PropertyAssignment); - if (assign.initializer.kind !== ts.SyntaxKind.Identifier) { - return false; - } - const id = assign.initializer as ts.Identifier; - const res = identifierIsMetadata(id, ngMetadata, checker); - - return res; -} - -function isTypeProperty(prop: ts.ObjectLiteralElement): boolean { - if (prop.kind !== ts.SyntaxKind.PropertyAssignment) { - return false; - } - const assignment = prop as ts.PropertyAssignment; - if (assignment.name.kind !== ts.SyntaxKind.Identifier) { - return false; - } - const name = assignment.name as ts.Identifier; - - return name.text === 'type'; -} - -// Check if an identifier is part of the known Angular Metadata. -function identifierIsMetadata( - id: ts.Identifier, - metadata: ts.Node[], - checker: ts.TypeChecker, -): boolean { - const symbol = checker.getSymbolAtLocation(id); - if (!symbol || !symbol.declarations || !symbol.declarations.length) { - return false; - } - - return symbol - .declarations - .some((spec) => metadata.indexOf(spec) !== -1); -} - -// Check if an import is a tslib helper import (`import * as tslib from "tslib";`) -function isTslibImport(node: ts.ImportDeclaration): boolean { - return !!(node.moduleSpecifier && - node.moduleSpecifier.kind === ts.SyntaxKind.StringLiteral && - (node.moduleSpecifier as ts.StringLiteral).text === 'tslib' && - node.importClause && - node.importClause.namedBindings && - node.importClause.namedBindings.kind === ts.SyntaxKind.NamespaceImport); -} - -// Find all namespace imports for `tslib`. -function findTslibImports(node: ts.Node): ts.NamespaceImport[] { - const imports: ts.NamespaceImport[] = []; - ts.forEachChild(node, (child) => { - if (child.kind === ts.SyntaxKind.ImportDeclaration) { - const importDecl = child as ts.ImportDeclaration; - if (isTslibImport(importDecl)) { - const importClause = importDecl.importClause as ts.ImportClause; - const namespaceImport = importClause.namedBindings as ts.NamespaceImport; - imports.push(namespaceImport); - } - } - }); - - return imports; -} - -// Check if an identifier is part of the known tslib identifiers. -function identifierIsTslib( - id: ts.Identifier, - tslibImports: ts.NamespaceImport[], - checker: ts.TypeChecker, -): boolean { - const symbol = checker.getSymbolAtLocation(id); - if (!symbol || !symbol.declarations || !symbol.declarations.length) { - return false; - } - - return symbol - .declarations - .some((spec) => tslibImports.indexOf(spec as ts.NamespaceImport) !== -1); -} - -// Check if a function call is a tslib helper. -function isTslibHelper( - callExpr: ts.CallExpression, - helper: string, - tslibImports: ts.NamespaceImport[], - checker: ts.TypeChecker, -) { - - let callExprIdent = callExpr.expression as ts.Identifier; - - if (callExpr.expression.kind !== ts.SyntaxKind.Identifier) { - if (callExpr.expression.kind === ts.SyntaxKind.PropertyAccessExpression) { - const propAccess = callExpr.expression as ts.PropertyAccessExpression; - const left = propAccess.expression; - callExprIdent = propAccess.name; - - if (left.kind !== ts.SyntaxKind.Identifier) { - return false; - } - - const id = left as ts.Identifier; - - if (!identifierIsTslib(id, tslibImports, checker)) { - return false; - } - - } else { - return false; - } - } - - // node.text on a name that starts with two underscores will return three instead. - // Unless it's an expression like tslib.__decorate, in which case it's only 2. - if (callExprIdent.text !== `_${helper}` && callExprIdent.text !== helper) { - return false; - } - - return true; -} diff --git a/packages/angular_devkit/build_optimizer/src/transforms/scrub-file_spec.ts b/packages/angular_devkit/build_optimizer/src/transforms/scrub-file_spec.ts deleted file mode 100644 index f9bae119b78c..000000000000 --- a/packages/angular_devkit/build_optimizer/src/transforms/scrub-file_spec.ts +++ /dev/null @@ -1,594 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable:no-big-function -// tslint:disable-next-line:no-implicit-dependencies -import { tags } from '@angular-devkit/core'; -import { transformJavascript } from '../helpers/transform-javascript'; -import { getScrubFileTransformer, testScrubFile } from './scrub-file'; - - -const transform = (content: string) => transformJavascript( - { content, getTransforms: [getScrubFileTransformer], typeCheck: true }).content; - -describe('scrub-file', () => { - const clazz = 'var Clazz = (function () { function Clazz() { } return Clazz; }());'; - - describe('decorators', () => { - it('removes top-level Angular decorators', () => { - const output = tags.stripIndent` - import { Injectable } from '@angular/core'; - ${clazz} - `; - const input = tags.stripIndent` - ${output} - Clazz.decorators = [ { type: Injectable } ]; - `; - - expect(testScrubFile(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('removes nested Angular decorators', () => { - const output = tags.stripIndent` - import { Injectable } from '@angular/core'; - var Clazz = (function () { - function Clazz() { } - return Clazz; - }()); - `; - const input = tags.stripIndent` - import { Injectable } from '@angular/core'; - var Clazz = (function () { - function Clazz() {} - Clazz.decorators = [ { type: Injectable } ]; - return Clazz; - }()); - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('doesn\'t remove non Angular decorators', () => { - const input = tags.stripIndent` - import { Injectable } from 'another-lib'; - ${clazz} - Clazz.decorators = [{ type: Injectable }]; - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${input}`); - }); - - it('leaves non-Angular decorators in mixed arrays', () => { - const input = tags.stripIndent` - import { Injectable } from '@angular/core'; - import { NotInjectable } from 'another-lib'; - ${clazz} - Clazz.decorators = [{ type: Injectable }, { type: NotInjectable }]; - `; - const output = tags.stripIndent` - import { Injectable } from '@angular/core'; - import { NotInjectable } from 'another-lib'; - ${clazz} - Clazz.decorators = [{ type: NotInjectable }]; - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - }); - - describe('__decorate', () => { - it('removes Angular decorators calls in __decorate', () => { - const output = tags.stripIndent` - import { Component, Injectable } from '@angular/core'; - var Clazz = (function () { - function Clazz() { } - return Clazz; - }()); - `; - const input = tags.stripIndent` - import { Component, Injectable } from '@angular/core'; - var Clazz = (function () { - function Clazz() { } - Clazz = __decorate([ - Injectable(), - Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.css'] - }) - ], Clazz); - return Clazz; - }()); - `; - - expect(testScrubFile(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('removes constructor parameter metadata in __decorate', () => { - const output = tags.stripIndent` - import { Component, ElementRef } from '@angular/core'; - import { LibService } from 'another-lib'; - var Clazz = (function () { - function Clazz() { } - return Clazz; - }()); - `; - const input = tags.stripIndent` - import { Component, ElementRef } from '@angular/core'; - import { LibService } from 'another-lib'; - var Clazz = (function () { - function Clazz() { } - Clazz = __decorate([ - Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.css'] - }), - __metadata("design:paramtypes", [ElementRef, LibService]) - ], Clazz); - return Clazz; - }()); - `; - - expect(testScrubFile(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('removes constructor parameter metadata when static properties are present', () => { - const output = tags.stripIndent` - import { Injectable } from '@angular/core'; - import { Logger } from 'another-lib'; - var GaService = (function () { - function GaService(logger) { - this.logger = logger; - } - GaService_1 = GaService; - GaService.prototype.initializeGa = function () { - console.log(GaService_1.initializeDelay); - }; - GaService.initializeDelay = 1000; - return GaService; - var GaService_1; - }()); - `; - const input = tags.stripIndent` - import { Injectable } from '@angular/core'; - import { Logger } from 'another-lib'; - var GaService = (function () { - function GaService(logger) { - this.logger = logger; - } - GaService_1 = GaService; - GaService.prototype.initializeGa = function () { - console.log(GaService_1.initializeDelay); - }; - GaService.initializeDelay = 1000; - GaService = GaService_1 = __decorate([ - Injectable(), - __metadata("design:paramtypes", [Logger]) - ], GaService); - return GaService; - var GaService_1; - }()); - `; - - expect(testScrubFile(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('doesn\t remove constructor parameter metadata for whitelisted classes', () => { - const input = tags.stripIndent` - import { ElementRef } from '@angular/core'; - import { LibService } from 'another-lib'; - var BrowserPlatformLocation = (function () { - function BrowserPlatformLocation() { } - BrowserPlatformLocation = __decorate([ - __metadata("design:paramtypes", [ElementRef, LibService]) - ], BrowserPlatformLocation); - return BrowserPlatformLocation; - }()); - `; - - expect(testScrubFile(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${input}`); - }); - - it('removes only Angular decorators calls in __decorate', () => { - const output = tags.stripIndent` - import { Component } from '@angular/core'; - import { NotComponent } from 'another-lib'; - var Clazz = (function () { - function Clazz() { } - Clazz = __decorate([ - NotComponent() - ], Clazz); - return Clazz; - }()); - `; - const input = tags.stripIndent` - import { Component } from '@angular/core'; - import { NotComponent } from 'another-lib'; - var Clazz = (function () { - function Clazz() { } - Clazz = __decorate([ - NotComponent(), - Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.css'] - }) - ], Clazz); - return Clazz; - }()); - `; - - expect(testScrubFile(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('recognizes tslib as well', () => { - const input = tags.stripIndent` - import * as tslib from "tslib"; - import * as tslib_2 from "tslib"; - import { Component } from '@angular/core'; - import { NotComponent } from 'another-lib'; - var Clazz = (function () { - function Clazz() { } - Clazz = tslib.__decorate([ - NotComponent(), - Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.css'] - }) - ], Clazz); - return Clazz; - }()); - - var Clazz2 = (function () { - function Clazz2() { } - Clazz2 = tslib_2.__decorate([ - NotComponent(), - Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.css'] - }) - ], Clazz2); - return Clazz2; - }()); - `; - const output = tags.stripIndent` - import * as tslib from "tslib"; - import * as tslib_2 from "tslib"; - import { Component } from '@angular/core'; - import { NotComponent } from 'another-lib'; - var Clazz = (function () { - function Clazz() { } - Clazz = tslib.__decorate([ - NotComponent() - ], Clazz); - return Clazz; - }()); - - var Clazz2 = (function () { - function Clazz2() { } - Clazz2 = tslib_2.__decorate([ - NotComponent() - ], Clazz2); - return Clazz2; - }()); - `; - - expect(testScrubFile(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - }); - - describe('__metadata', () => { - it('removes Angular decorators metadata', () => { - const output = tags.stripIndent` - import { Input, Output, EventEmitter, HostListener } from '@angular/core'; - var Clazz = (function () { - function Clazz() { - this.change = new EventEmitter(); - } - return Clazz; - }()); - `; - const input = tags.stripIndent` - import { Input, Output, EventEmitter, HostListener } from '@angular/core'; - import { NotInput } from 'another-lib'; - var Clazz = (function () { - function Clazz() { - this.change = new EventEmitter(); - } - __decorate([ - Input(), - __metadata("design:type", Object) - ], Clazz.prototype, "selected", void 0); - __decorate([ - Output(), - __metadata("design:type", Object) - ], Clazz.prototype, "change", void 0); - __decorate([ - HostListener('document:keydown.escape'), - __metadata("design:type", Function), - __metadata("design:paramtypes", []), - __metadata("design:returntype", void 0) - ], Clazz.prototype, "onKeyDown", null); - return Clazz; - }()); - `; - - expect(testScrubFile(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('removes only Angular decorator metadata', () => { - const output = tags.stripIndent` - import { Input } from '@angular/core'; - import { NotInput } from 'another-lib'; - var Clazz = (function () { - function Clazz() { } - __decorate([ - NotInput(), - __metadata("design:type", Object) - ], Clazz.prototype, "other", void 0); - Clazz.prototype.myMethod = function () { return 'bar'; }; - __decorate([ - myDecorator(), - __metadata("design:type", Function), - __metadata("design:paramtypes", []), - __metadata("design:returntype", void 0) - ], MyClass.prototype, "myMethod", null); - return Clazz; - }()); - `; - const input = tags.stripIndent` - import { Input } from '@angular/core'; - import { NotInput } from 'another-lib'; - var Clazz = (function () { - function Clazz() { } - __decorate([ - Input(), - __metadata("design:type", Object) - ], Clazz.prototype, "selected", void 0); - __decorate([ - NotInput(), - __metadata("design:type", Object) - ], Clazz.prototype, "other", void 0); - Clazz.prototype.myMethod = function () { return 'bar'; }; - __decorate([ - myDecorator(), - __metadata("design:type", Function), - __metadata("design:paramtypes", []), - __metadata("design:returntype", void 0) - ], MyClass.prototype, "myMethod", null); - return Clazz; - }()); - `; - - expect(testScrubFile(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('recognizes tslib as well', () => { - const input = tags.stripIndent` - import * as tslib from "tslib"; - import * as tslib_2 from "tslib"; - import { Input } from '@angular/core'; - var Clazz = (function () { - function Clazz() { } - tslib.__decorate([ - Input(), - tslib.__metadata("design:type", Object) - ], Clazz.prototype, "selected", void 0); - return Clazz; - }()); - - var Clazz2 = (function () { - function Clazz2() { } - tslib_2.__decorate([ - Input(), - tslib_2.__metadata("design:type", Object) - ], Clazz.prototype, "selected", void 0); - return Clazz2; - }()); - `; - const output = tags.stripIndent` - import * as tslib from "tslib"; - import * as tslib_2 from "tslib"; - import { Input } from '@angular/core'; - var Clazz = (function () { - function Clazz() { } - return Clazz; - }()); - - var Clazz2 = (function () { - function Clazz2() { } - return Clazz2; - }()); - `; - - expect(testScrubFile(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - }); - - describe('__param', () => { - it('removes all constructor parameters and their type metadata', () => { - const output = tags.stripIndent` - var MyClass = /** @class */ (function () { - function MyClass(myParam) { - this.myProp = 'foo'; - } - MyClass = __decorate([ - myDecorator() - ], MyClass); - return MyClass; - }()); - `; - const input = tags.stripIndent` - var MyClass = /** @class */ (function () { - function MyClass(myParam) { - this.myProp = 'foo'; - } - MyClass = __decorate([ - myDecorator(), - __param(0, myDecorator()), - __metadata("design:paramtypes", [Number]) - ], MyClass); - return MyClass; - }()); - `; - - expect(testScrubFile(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - }); - - describe('propDecorators', () => { - it('removes top-level Angular propDecorators', () => { - const output = tags.stripIndent` - import { Input } from '@angular/core'; - ${clazz} - `; - const input = tags.stripIndent` - ${output} - Clazz.propDecorators = { 'ngIf': [{ type: Input }] } - `; - - expect(testScrubFile(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('removes nested Angular propDecorators', () => { - const output = tags.stripIndent` - import { Input } from '@angular/core'; - var Clazz = (function () { - function Clazz() { } - return Clazz; - }()); - `; - const input = tags.stripIndent` - import { Input } from '@angular/core'; - var Clazz = (function () { - function Clazz() {} - Clazz.propDecorators = { 'ngIf': [{ type: Input }] }; - return Clazz; - }()); - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('doesn\'t remove non Angular propDecorators', () => { - const input = tags.stripIndent` - import { Input } from 'another-lib'; - ${clazz} - Clazz.propDecorators = { 'ngIf': [{ type: Input }] }; - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${input}`); - }); - - it('leaves non-Angular propDecorators in mixed arrays', () => { - const output = tags.stripIndent` - import { Input } from '@angular/core'; - import { NotInput } from 'another-lib'; - ${clazz} - Clazz.propDecorators = { - 'notNgIf': [{ type: NotInput }] - }; - `; - const input = tags.stripIndent` - import { Input } from '@angular/core'; - import { NotInput } from 'another-lib'; - ${clazz} - Clazz.propDecorators = { - 'ngIf': [{ type: Input }], - 'notNgIf': [{ type: NotInput }] - }; - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - }); - - describe('ctorParameters', () => { - it('removes empty constructor parameters', () => { - const output = tags.stripIndent` - ${clazz} - `; - const input = tags.stripIndent` - ${output} - Clazz.ctorParameters = function () { return []; }; - `; - - expect(testScrubFile(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('removes non-empty top-level style constructor parameters', () => { - const output = tags.stripIndent` - ${clazz} - `; - const input = tags.stripIndent` - ${clazz} - Clazz.ctorParameters = function () { return [{type: Injector}]; }; - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - - it('removes top-level Angular constructor parameters in es2015', () => { - const output = tags.stripIndent` - class Clazz extends BaseClazz { constructor(e) { super(e); } } - `; - const input = tags.stripIndent` - ${output} - Clazz.ctorParameters = () => [ { type: Injectable } ]; - `; - - expect(testScrubFile(input)).toBeTruthy(); - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('removes nested constructor parameters', () => { - const output = tags.stripIndent` - import { Injector } from '@angular/core'; - var Clazz = (function () { - function Clazz() { } - return Clazz; - }()); - `; - const input = tags.stripIndent` - import { Injector } from '@angular/core'; - var Clazz = (function () { - function Clazz() {} - Clazz.ctorParameters = function () { return [{type: Injector}]; }; - return Clazz; - }()); - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('doesn\'t remove constructor parameters from whitelisted classes', () => { - const input = tags.stripIndent` - ${clazz.replace('Clazz', 'PlatformRef_')} - PlatformRef_.ctorParameters = function () { return []; }; - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${input}`); - }); - }); -}); diff --git a/packages/angular_devkit/build_optimizer/src/transforms/wrap-enums.ts b/packages/angular_devkit/build_optimizer/src/transforms/wrap-enums.ts deleted file mode 100644 index 6a31fdcb5172..000000000000 --- a/packages/angular_devkit/build_optimizer/src/transforms/wrap-enums.ts +++ /dev/null @@ -1,496 +0,0 @@ -/** - * @license - * 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 - */ -import * as ts from 'typescript'; - -function isBlockLike(node: ts.Node): node is ts.BlockLike { - return node.kind === ts.SyntaxKind.Block - || node.kind === ts.SyntaxKind.ModuleBlock - || node.kind === ts.SyntaxKind.CaseClause - || node.kind === ts.SyntaxKind.DefaultClause - || node.kind === ts.SyntaxKind.SourceFile; -} - -export function getWrapEnumsTransformer(): ts.TransformerFactory { - return (context: ts.TransformationContext): ts.Transformer => { - const transformer: ts.Transformer = sf => { - const result = visitBlockStatements(sf.statements, context); - - return ts.updateSourceFileNode(sf, ts.setTextRange(result, sf.statements)); - }; - - return transformer; - }; -} - -function visitBlockStatements( - statements: ts.NodeArray, - context: ts.TransformationContext, -): ts.NodeArray { - - // copy of statements to modify; lazy initialized - let updatedStatements: Array | undefined; - - const visitor: ts.Visitor = (node) => { - if (isBlockLike(node)) { - let result = visitBlockStatements(node.statements, context); - if (result === node.statements) { - return node; - } - result = ts.setTextRange(result, node.statements); - switch (node.kind) { - case ts.SyntaxKind.Block: - return ts.updateBlock(node, result); - case ts.SyntaxKind.ModuleBlock: - return ts.updateModuleBlock(node, result); - case ts.SyntaxKind.CaseClause: - return ts.updateCaseClause(node, node.expression, result); - case ts.SyntaxKind.DefaultClause: - return ts.updateDefaultClause(node, result); - default: - return node; - } - } else { - return ts.visitEachChild(node, visitor, context); - } - }; - - // 'oIndex' is the original statement index; 'uIndex' is the updated statement index - for (let oIndex = 0, uIndex = 0; oIndex < statements.length; oIndex++, uIndex++) { - const currentStatement = statements[oIndex]; - - // these can't contain an enum declaration - if (currentStatement.kind === ts.SyntaxKind.ImportDeclaration) { - continue; - } - - // enum declarations must: - // * not be last statement - // * be a variable statement - // * have only one declaration - // * have an identifer as a declaration name - if (oIndex < statements.length - 1 - && ts.isVariableStatement(currentStatement) - && currentStatement.declarationList.declarations.length === 1) { - - const variableDeclaration = currentStatement.declarationList.declarations[0]; - if (ts.isIdentifier(variableDeclaration.name)) { - const name = variableDeclaration.name.text; - - if (!variableDeclaration.initializer) { - const iife = findTs2_3EnumIife(name, statements[oIndex + 1]); - if (iife) { - // found an enum - if (!updatedStatements) { - updatedStatements = statements.slice(); - } - // update IIFE and replace variable statement and old IIFE - updatedStatements.splice(uIndex, 2, updateEnumIife( - currentStatement, - iife[0], - iife[1], - )); - // skip IIFE statement - oIndex++; - continue; - } - } else if (ts.isObjectLiteralExpression(variableDeclaration.initializer) - && variableDeclaration.initializer.properties.length === 0) { - const enumStatements = findTs2_2EnumStatements(name, statements, oIndex + 1); - if (enumStatements.length > 0) { - // found an enum - if (!updatedStatements) { - updatedStatements = statements.slice(); - } - // create wrapper and replace variable statement and enum member statements - updatedStatements.splice(uIndex, enumStatements.length + 1, createWrappedEnum( - name, - currentStatement, - enumStatements, - variableDeclaration.initializer, - )); - // skip enum member declarations - oIndex += enumStatements.length; - continue; - } - } else if (ts.isObjectLiteralExpression(variableDeclaration.initializer) - && variableDeclaration.initializer.properties.length !== 0) { - const literalPropertyCount = variableDeclaration.initializer.properties.length; - const enumStatements = findEnumNameStatements(name, statements, oIndex + 1); - if (enumStatements.length === literalPropertyCount) { - // found an enum - if (!updatedStatements) { - updatedStatements = statements.slice(); - } - // create wrapper and replace variable statement and enum member statements - updatedStatements.splice(uIndex, enumStatements.length + 1, createWrappedEnum( - name, - currentStatement, - enumStatements, - variableDeclaration.initializer, - )); - // skip enum member declarations - oIndex += enumStatements.length; - continue; - } - } - } - } - - const result = ts.visitNode(currentStatement, visitor); - if (result !== currentStatement) { - if (!updatedStatements) { - updatedStatements = statements.slice(); - } - updatedStatements[uIndex] = result; - } - } - - // if changes, return updated statements - // otherwise, return original array instance - return updatedStatements ? ts.createNodeArray(updatedStatements) : statements; -} - -// TS 2.3 enums have statements that are inside a IIFE. -function findTs2_3EnumIife( - name: string, - statement: ts.Statement, -): [ts.CallExpression, ts.Expression | undefined] | null { - if (!ts.isExpressionStatement(statement)) { - return null; - } - - let expression = statement.expression; - while (ts.isParenthesizedExpression(expression)) { - expression = expression.expression; - } - - if (!expression || !ts.isCallExpression(expression) || expression.arguments.length !== 1) { - return null; - } - - const callExpression = expression; - let exportExpression; - - let argument = expression.arguments[0]; - if (!ts.isBinaryExpression(argument)) { - return null; - } - - if (!ts.isIdentifier(argument.left) || argument.left.text !== name) { - return null; - } - - let potentialExport = false; - if (argument.operatorToken.kind === ts.SyntaxKind.FirstAssignment) { - if (!ts.isBinaryExpression(argument.right) - || argument.right.operatorToken.kind !== ts.SyntaxKind.BarBarToken) { - return null; - } - - potentialExport = true; - argument = argument.right; - } - - if (!ts.isBinaryExpression(argument)) { - return null; - } - - if (argument.operatorToken.kind !== ts.SyntaxKind.BarBarToken) { - return null; - } - - if (potentialExport && !ts.isIdentifier(argument.left)) { - exportExpression = argument.left; - } - - expression = expression.expression; - while (ts.isParenthesizedExpression(expression)) { - expression = expression.expression; - } - - if (!expression || !ts.isFunctionExpression(expression) || expression.parameters.length !== 1) { - return null; - } - - const parameter = expression.parameters[0]; - if (!ts.isIdentifier(parameter.name)) { - return null; - } - - // The name of the parameter can be different than the name of the enum if it was renamed - // due to scope hoisting. - const parameterName = parameter.name.text; - - // In TS 2.3 enums, the IIFE contains only expressions with a certain format. - // If we find any that is different, we ignore the whole thing. - for (let bodyIndex = 0; bodyIndex < expression.body.statements.length; ++bodyIndex) { - const bodyStatement = expression.body.statements[bodyIndex]; - - if (!ts.isExpressionStatement(bodyStatement) || !bodyStatement.expression) { - return null; - } - - if (!ts.isBinaryExpression(bodyStatement.expression) - || bodyStatement.expression.operatorToken.kind !== ts.SyntaxKind.FirstAssignment) { - return null; - } - - const assignment = bodyStatement.expression.left; - const value = bodyStatement.expression.right; - if (!ts.isElementAccessExpression(assignment) || !ts.isStringLiteral(value)) { - return null; - } - - if (!ts.isIdentifier(assignment.expression) || assignment.expression.text !== parameterName) { - return null; - } - - const memberArgument = assignment.argumentExpression; - if (!memberArgument || !ts.isBinaryExpression(memberArgument) - || memberArgument.operatorToken.kind !== ts.SyntaxKind.FirstAssignment) { - return null; - } - - - if (!ts.isElementAccessExpression(memberArgument.left)) { - return null; - } - - if (!ts.isIdentifier(memberArgument.left.expression) - || memberArgument.left.expression.text !== parameterName) { - return null; - } - - if (!memberArgument.left.argumentExpression - || !ts.isStringLiteral(memberArgument.left.argumentExpression)) { - return null; - } - - if (memberArgument.left.argumentExpression.text !== value.text) { - return null; - } - } - - return [callExpression, exportExpression]; -} - -// TS 2.2 enums have statements after the variable declaration, with index statements followed -// by value statements. -function findTs2_2EnumStatements( - name: string, - statements: ts.NodeArray, - statementOffset: number, -): ts.Statement[] { - const enumValueStatements: ts.Statement[] = []; - const memberNames: string[] = []; - - let index = statementOffset; - for (; index < statements.length; ++index) { - // Ensure all statements are of the expected format and using the right identifer. - // When we find a statement that isn't part of the enum, return what we collected so far. - const current = statements[index]; - if (!ts.isExpressionStatement(current) || !ts.isBinaryExpression(current.expression)) { - break; - } - - const property = current.expression.left; - if (!property || !ts.isPropertyAccessExpression(property)) { - break; - } - - if (!ts.isIdentifier(property.expression) || property.expression.text !== name) { - break; - } - - memberNames.push(property.name.text); - enumValueStatements.push(current); - } - - if (enumValueStatements.length === 0) { - return []; - } - - const enumNameStatements = findEnumNameStatements(name, statements, index, memberNames); - if (enumNameStatements.length !== enumValueStatements.length) { - return []; - } - - return enumValueStatements.concat(enumNameStatements); -} - -// Tsickle enums have a variable statement with indexes, followed by value statements. -// See https://github.com/angular/devkit/issues/229#issuecomment-338512056 fore more information. -function findEnumNameStatements( - name: string, - statements: ts.NodeArray, - statementOffset: number, - memberNames?: string[], -): ts.Statement[] { - const enumStatements: ts.Statement[] = []; - - for (let index = statementOffset; index < statements.length; ++index) { - // Ensure all statements are of the expected format and using the right identifer. - // When we find a statement that isn't part of the enum, return what we collected so far. - const current = statements[index]; - if (!ts.isExpressionStatement(current) || !ts.isBinaryExpression(current.expression)) { - break; - } - - const access = current.expression.left; - const value = current.expression.right; - if (!access || !ts.isElementAccessExpression(access) || !value || !ts.isStringLiteral(value)) { - break; - } - - if (memberNames && !memberNames.includes(value.text)) { - break; - } - - if (!ts.isIdentifier(access.expression) || access.expression.text !== name) { - break; - } - - if (!access.argumentExpression || !ts.isPropertyAccessExpression(access.argumentExpression)) { - break; - } - - const enumExpression = access.argumentExpression.expression; - if (!ts.isIdentifier(enumExpression) || enumExpression.text !== name) { - break; - } - - if (value.text !== access.argumentExpression.name.text) { - break; - } - - enumStatements.push(current); - } - - return enumStatements; -} - -function addPureComment(node: T): T { - const pureFunctionComment = '@__PURE__'; - - return ts.addSyntheticLeadingComment( - node, - ts.SyntaxKind.MultiLineCommentTrivia, - pureFunctionComment, - false, - ); -} - -function updateHostNode( - hostNode: ts.VariableStatement, - expression: ts.Expression, -): ts.Statement { - - // Update existing host node with the pure comment before the variable declaration initializer. - const variableDeclaration = hostNode.declarationList.declarations[0]; - const outerVarStmt = ts.updateVariableStatement( - hostNode, - hostNode.modifiers, - ts.updateVariableDeclarationList( - hostNode.declarationList, - [ - ts.updateVariableDeclaration( - variableDeclaration, - variableDeclaration.name, - variableDeclaration.type, - expression, - ), - ], - ), - ); - - return outerVarStmt; -} - -function updateEnumIife( - hostNode: ts.VariableStatement, - iife: ts.CallExpression, - exportAssignment?: ts.Expression, -): ts.Statement { - if (!ts.isParenthesizedExpression(iife.expression) - || !ts.isFunctionExpression(iife.expression.expression)) { - throw new Error('Invalid IIFE Structure'); - } - - // Ignore export assignment if variable is directly exported - if (hostNode.modifiers - && hostNode.modifiers.findIndex(m => m.kind == ts.SyntaxKind.ExportKeyword) != -1) { - exportAssignment = undefined; - } - - const expression = iife.expression.expression; - const updatedFunction = ts.updateFunctionExpression( - expression, - expression.modifiers, - expression.asteriskToken, - expression.name, - expression.typeParameters, - expression.parameters, - expression.type, - ts.updateBlock( - expression.body, - [ - ...expression.body.statements, - ts.createReturn(expression.parameters[0].name as ts.Identifier), - ], - ), - ); - - let arg: ts.Expression = ts.createObjectLiteral(); - if (exportAssignment) { - arg = ts.createBinary(exportAssignment, ts.SyntaxKind.BarBarToken, arg); - } - const updatedIife = ts.updateCall( - iife, - ts.updateParen( - iife.expression, - updatedFunction, - ), - iife.typeArguments, - [arg], - ); - - let value: ts.Expression = addPureComment(updatedIife); - if (exportAssignment) { - value = ts.createBinary( - exportAssignment, - ts.SyntaxKind.FirstAssignment, - updatedIife); - } - - return updateHostNode(hostNode, value); -} - -function createWrappedEnum( - name: string, - hostNode: ts.VariableStatement, - statements: Array, - literalInitializer: ts.ObjectLiteralExpression | undefined, -): ts.Statement { - literalInitializer = literalInitializer || ts.createObjectLiteral(); - const innerVarStmt = ts.createVariableStatement( - undefined, - ts.createVariableDeclarationList([ - ts.createVariableDeclaration(name, undefined, literalInitializer), - ]), - ); - - const innerReturn = ts.createReturn(ts.createIdentifier(name)); - - const iife = ts.createImmediatelyInvokedFunctionExpression([ - innerVarStmt, - ...statements, - innerReturn, - ]); - - return updateHostNode(hostNode, addPureComment(ts.createParen(iife))); -} diff --git a/packages/angular_devkit/build_optimizer/src/transforms/wrap-enums_spec.ts b/packages/angular_devkit/build_optimizer/src/transforms/wrap-enums_spec.ts deleted file mode 100644 index 7f1319fde9cb..000000000000 --- a/packages/angular_devkit/build_optimizer/src/transforms/wrap-enums_spec.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable-next-line:no-implicit-dependencies -import { tags } from '@angular-devkit/core'; -import { transformJavascript } from '../helpers/transform-javascript'; -import { getWrapEnumsTransformer } from './wrap-enums'; - - -const transform = (content: string) => transformJavascript( - { content, getTransforms: [getWrapEnumsTransformer] }).content; - -// tslint:disable-next-line:no-big-function -describe('wrap-enums', () => { - it('wraps ts 2.2 enums in IIFE', () => { - const input = tags.stripIndent` - export var ChangeDetectionStrategy = {}; - ChangeDetectionStrategy.OnPush = 0; - ChangeDetectionStrategy.Default = 1; - ChangeDetectionStrategy[ChangeDetectionStrategy.OnPush] = "OnPush"; - ChangeDetectionStrategy[ChangeDetectionStrategy.Default] = "Default"; - `; - const output = tags.stripIndent` - export var ChangeDetectionStrategy = /*@__PURE__*/ (function () { - var ChangeDetectionStrategy = {}; - ChangeDetectionStrategy.OnPush = 0; - ChangeDetectionStrategy.Default = 1; - ChangeDetectionStrategy[ChangeDetectionStrategy.OnPush] = "OnPush"; - ChangeDetectionStrategy[ChangeDetectionStrategy.Default] = "Default"; - return ChangeDetectionStrategy; - }()); - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('wraps ts >2.3 enums in IIFE', () => { - const input = tags.stripIndent` - export var ChangeDetectionStrategy; - (function (ChangeDetectionStrategy) { - ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; - ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; - })(ChangeDetectionStrategy || (ChangeDetectionStrategy = {})); - `; - const output = tags.stripIndent` - export var ChangeDetectionStrategy = /*@__PURE__*/ (function (ChangeDetectionStrategy) { - ChangeDetectionStrategy[ChangeDetectionStrategy["OnPush"] = 0] = "OnPush"; - ChangeDetectionStrategy[ChangeDetectionStrategy["Default"] = 1] = "Default"; - return ChangeDetectionStrategy; - })({}); - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('wraps ts >2.3 enums in IIFE, even if they have funny numbers', () => { - const input = tags.stripIndent` - export var AnimatorControlState; - (function (AnimatorControlState) { - AnimatorControlState[AnimatorControlState["INITIALIZED"] = 1] = "INITIALIZED"; - AnimatorControlState[AnimatorControlState["STARTED"] = 2] = "STARTED"; - AnimatorControlState[AnimatorControlState["FINISHED"] = 3] = "FINISHED"; - AnimatorControlState[AnimatorControlState["DESTROYED"] = 4] = "DESTROYED"; - })(AnimatorControlState || (AnimatorControlState = {})); - `; - const output = tags.stripIndent` - export var AnimatorControlState = /*@__PURE__*/ (function (AnimatorControlState) { - AnimatorControlState[AnimatorControlState["INITIALIZED"] = 1] = "INITIALIZED"; - AnimatorControlState[AnimatorControlState["STARTED"] = 2] = "STARTED"; - AnimatorControlState[AnimatorControlState["FINISHED"] = 3] = "FINISHED"; - AnimatorControlState[AnimatorControlState["DESTROYED"] = 4] = "DESTROYED"; - return AnimatorControlState; - })({}); - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('wraps ts >2.3 enums in IIFE, even if they were renamed due to scope hoisting', () => { - const input = tags.stripIndent` - var TokenType$1; - (function (TokenType) { - TokenType[TokenType["TAG_OPEN_START"] = 0] = "TAG_OPEN_START"; - TokenType[TokenType["TAG_OPEN_END"] = 1] = "TAG_OPEN_END"; - TokenType[TokenType["TAG_OPEN_END_VOID"] = 2] = "TAG_OPEN_END_VOID"; - TokenType[TokenType["TAG_CLOSE"] = 3] = "TAG_CLOSE"; - TokenType[TokenType["TEXT"] = 4] = "TEXT"; - TokenType[TokenType["ESCAPABLE_RAW_TEXT"] = 5] = "ESCAPABLE_RAW_TEXT"; - TokenType[TokenType["RAW_TEXT"] = 6] = "RAW_TEXT"; - TokenType[TokenType["COMMENT_START"] = 7] = "COMMENT_START"; - TokenType[TokenType["COMMENT_END"] = 8] = "COMMENT_END"; - TokenType[TokenType["CDATA_START"] = 9] = "CDATA_START"; - TokenType[TokenType["CDATA_END"] = 10] = "CDATA_END"; - TokenType[TokenType["ATTR_NAME"] = 11] = "ATTR_NAME"; - TokenType[TokenType["ATTR_VALUE"] = 12] = "ATTR_VALUE"; - TokenType[TokenType["DOC_TYPE"] = 13] = "DOC_TYPE"; - TokenType[TokenType["EXPANSION_FORM_START"] = 14] = "EXPANSION_FORM_START"; - TokenType[TokenType["EXPANSION_CASE_VALUE"] = 15] = "EXPANSION_CASE_VALUE"; - TokenType[TokenType["EXPANSION_CASE_EXP_START"] = 16] = "EXPANSION_CASE_EXP_START"; - TokenType[TokenType["EXPANSION_CASE_EXP_END"] = 17] = "EXPANSION_CASE_EXP_END"; - TokenType[TokenType["EXPANSION_FORM_END"] = 18] = "EXPANSION_FORM_END"; - TokenType[TokenType["EOF"] = 19] = "EOF"; - })(TokenType$1 || (TokenType$1 = {})); - `; - const output = tags.stripIndent` - var TokenType$1 = /*@__PURE__*/ (function (TokenType) { - TokenType[TokenType["TAG_OPEN_START"] = 0] = "TAG_OPEN_START"; - TokenType[TokenType["TAG_OPEN_END"] = 1] = "TAG_OPEN_END"; - TokenType[TokenType["TAG_OPEN_END_VOID"] = 2] = "TAG_OPEN_END_VOID"; - TokenType[TokenType["TAG_CLOSE"] = 3] = "TAG_CLOSE"; - TokenType[TokenType["TEXT"] = 4] = "TEXT"; - TokenType[TokenType["ESCAPABLE_RAW_TEXT"] = 5] = "ESCAPABLE_RAW_TEXT"; - TokenType[TokenType["RAW_TEXT"] = 6] = "RAW_TEXT"; - TokenType[TokenType["COMMENT_START"] = 7] = "COMMENT_START"; - TokenType[TokenType["COMMENT_END"] = 8] = "COMMENT_END"; - TokenType[TokenType["CDATA_START"] = 9] = "CDATA_START"; - TokenType[TokenType["CDATA_END"] = 10] = "CDATA_END"; - TokenType[TokenType["ATTR_NAME"] = 11] = "ATTR_NAME"; - TokenType[TokenType["ATTR_VALUE"] = 12] = "ATTR_VALUE"; - TokenType[TokenType["DOC_TYPE"] = 13] = "DOC_TYPE"; - TokenType[TokenType["EXPANSION_FORM_START"] = 14] = "EXPANSION_FORM_START"; - TokenType[TokenType["EXPANSION_CASE_VALUE"] = 15] = "EXPANSION_CASE_VALUE"; - TokenType[TokenType["EXPANSION_CASE_EXP_START"] = 16] = "EXPANSION_CASE_EXP_START"; - TokenType[TokenType["EXPANSION_CASE_EXP_END"] = 17] = "EXPANSION_CASE_EXP_END"; - TokenType[TokenType["EXPANSION_FORM_END"] = 18] = "EXPANSION_FORM_END"; - TokenType[TokenType["EOF"] = 19] = "EOF"; - return TokenType; - })({}); - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('wraps tsickle enums in IIFE', () => { - const input = tags.stripIndent` - /** @enum {number} */ - var FormatWidth = { - Short: 0, - Medium: 1, - Long: 2, - Full: 3, - }; - FormatWidth[FormatWidth.Short] = "Short"; - FormatWidth[FormatWidth.Medium] = "Medium"; - FormatWidth[FormatWidth.Long] = "Long"; - FormatWidth[FormatWidth.Full] = "Full"; - `; - const output = tags.stripIndent` - /** @enum {number} */ var FormatWidth = /*@__PURE__*/ (function () { - var FormatWidth = { - Short: 0, - Medium: 1, - Long: 2, - Full: 3, - }; - FormatWidth[FormatWidth.Short] = "Short"; - FormatWidth[FormatWidth.Medium] = "Medium"; - FormatWidth[FormatWidth.Long] = "Long"; - FormatWidth[FormatWidth.Full] = "Full"; - return FormatWidth; - }()); - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('wraps enums with multi-line comments in IIFE', () => { - const input = tags.stripIndent` - /** - * Supported http methods. - * @deprecated use @angular/common/http instead - */ - var RequestMethod; - /** - * Supported http methods. - * @deprecated use @angular/common/http instead - */ - (function (RequestMethod) { - RequestMethod[RequestMethod["Get"] = 0] = "Get"; - RequestMethod[RequestMethod["Post"] = 1] = "Post"; - RequestMethod[RequestMethod["Put"] = 2] = "Put"; - RequestMethod[RequestMethod["Delete"] = 3] = "Delete"; - RequestMethod[RequestMethod["Options"] = 4] = "Options"; - RequestMethod[RequestMethod["Head"] = 5] = "Head"; - RequestMethod[RequestMethod["Patch"] = 6] = "Patch"; - })(RequestMethod || (RequestMethod = {})); - `; - // We need to interpolate this space because our editorconfig automatically strips - // trailing whitespace. - const space = ' '; - const output = tags.stripIndent` - /** - * Supported http methods. - * @deprecated use @angular/common/http instead - */ - var RequestMethod =${space} - /** - * Supported http methods. - * @deprecated use @angular/common/http instead - */ - /*@__PURE__*/ (function (RequestMethod) { - RequestMethod[RequestMethod["Get"] = 0] = "Get"; - RequestMethod[RequestMethod["Post"] = 1] = "Post"; - RequestMethod[RequestMethod["Put"] = 2] = "Put"; - RequestMethod[RequestMethod["Delete"] = 3] = "Delete"; - RequestMethod[RequestMethod["Options"] = 4] = "Options"; - RequestMethod[RequestMethod["Head"] = 5] = "Head"; - RequestMethod[RequestMethod["Patch"] = 6] = "Patch"; - return RequestMethod; - })({}); - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - - it('wraps exported enums in IIFE', () => { - const input = tags.stripIndent` - var ExportEnum; - (function (ExportEnum) { - ExportEnum[ExportEnum["A"] = 0] = "A"; - ExportEnum[ExportEnum["B"] = 1] = "B"; - ExportEnum[ExportEnum["C"] = 2] = "C"; - })(ExportEnum = exports.ExportEnum || (exports.ExportEnum = {})); - `; - const output = tags.stripIndent` - var ExportEnum = exports.ExportEnum = /*@__PURE__*/ (function (ExportEnum) { - ExportEnum[ExportEnum["A"] = 0] = "A"; - ExportEnum[ExportEnum["B"] = 1] = "B"; - ExportEnum[ExportEnum["C"] = 2] = "C"; - return ExportEnum; - })(exports.ExportEnum || {}); - `; - - expect(tags.oneLine`${transform(input)}`).toEqual(tags.oneLine`${output}`); - }); - -}); diff --git a/packages/angular_devkit/build_optimizer/webpack-loader/package.json b/packages/angular_devkit/build_optimizer/webpack-loader/package.json deleted file mode 100644 index a18ef898c128..000000000000 --- a/packages/angular_devkit/build_optimizer/webpack-loader/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "main": "../src/build-optimizer/webpack-loader.js" -} diff --git a/packages/angular_devkit/build_webpack/BUILD.bazel b/packages/angular_devkit/build_webpack/BUILD.bazel new file mode 100644 index 000000000000..10b435d66e06 --- /dev/null +++ b/packages/angular_devkit/build_webpack/BUILD.bazel @@ -0,0 +1,127 @@ +# Copyright Google LLC. All Rights Reserved. +# +# Use of this source code is governed by an MIT-style license that can be +# found in the LICENSE file at https://angular.dev/license + +load("@devinfra//bazel/api-golden:index.bzl", "api_golden_test_npm_package") +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") + +licenses(["notice"]) + +package(default_visibility = ["//visibility:public"]) + +npm_link_all_packages() + +ts_json_schema( + name = "webpack_schema", + src = "src/builders/webpack/schema.json", +) + +ts_json_schema( + name = "webpack_dev_server_schema", + src = "src/builders/webpack-dev-server/schema.json", +) + +RUNTIME_ASSETS = [ + "builders.json", + "package.json", + "src/builders/webpack-dev-server/schema.json", + "src/builders/webpack/schema.json", +] + +ts_project( + name = "build_webpack", + srcs = glob( + include = ["src/**/*.ts"], + exclude = [ + "src/test-utils.ts", + "**/*_spec.ts", + ], + ) + [ + "index.ts", + "//packages/angular_devkit/build_webpack:src/builders/webpack-dev-server/schema.ts", + "//packages/angular_devkit/build_webpack:src/builders/webpack/schema.ts", + ], + data = RUNTIME_ASSETS, + deps = [ + ":node_modules/@angular-devkit/architect", + ":node_modules/rxjs", + ":node_modules/webpack", + ":node_modules/webpack-dev-server", + "//:node_modules/@types/node", + ], +) + +ts_project( + name = "build_webpack_test_lib", + testonly = True, + srcs = glob( + include = [ + "src/**/*_spec.ts", + ], + ), + data = glob( + include = [ + "test/**/*", + ], + ), + deps = [ + ":build_webpack", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + ":node_modules/@ngtools/webpack", + "//:node_modules/@types/jasmine", + "//:node_modules/@types/node", + ], +) + +jasmine_test( + name = "build_webpack_test", + data = [ + "build_webpack_test_lib", + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/tslib", + "//:node_modules/typescript", + "//:node_modules/zone.js", + ], + env = { + # Force IPv4 to resolve RBE resolution issues + "NODE_OPTIONS": "--dns-result-order=ipv4first", + }, +) + +genrule( + name = "license", + srcs = ["//:LICENSE"], + outs = ["LICENSE"], + cmd = "cp $(execpath //:LICENSE) $@", +) + +npm_package( + name = "pkg", + pkg_deps = [ + "//packages/angular_devkit/architect:package.json", + ], + tags = ["release-package"], + deps = RUNTIME_ASSETS + [ + ":README.md", + ":build_webpack", + ":license", + ], +) + +api_golden_test_npm_package( + name = "build_webpack_api", + data = [ + ":npm_package", + "//goldens:public-api", + ], + golden_dir = "goldens/public-api/angular_devkit/build_webpack", + npm_package = "packages/angular_devkit/build_webpack/npm_package", +) diff --git a/packages/angular_devkit/build_webpack/README.md b/packages/angular_devkit/build_webpack/README.md index 49e19302d939..2663efe82dae 100644 --- a/packages/angular_devkit/build_webpack/README.md +++ b/packages/angular_devkit/build_webpack/README.md @@ -7,6 +7,7 @@ To use it on your Angular CLI app, follow these steps: - run `npm install @angular-devkit/build-webpack`. - create a webpack configuration. - add the following targets inside `angular.json`. + ``` "projects": { "app": { @@ -27,6 +28,7 @@ To use it on your Angular CLI app, follow these steps: } } ``` + - run `ng run app:build-webpack` to build, and `ng run app:serve-webpack` to serve. -All options, including `watch` and `stats`, are looked up inside the webpack configuration. \ No newline at end of file +All options, including `watch` and `stats`, are looked up inside the webpack configuration. diff --git a/packages/angular_devkit/build_webpack/builders.json b/packages/angular_devkit/build_webpack/builders.json index fe3bbee0c9e8..7c53a7eacdab 100644 --- a/packages/angular_devkit/build_webpack/builders.json +++ b/packages/angular_devkit/build_webpack/builders.json @@ -1,14 +1,24 @@ { "$schema": "../architect/src/builders-schema.json", "builders": { + "build": { + "implementation": "./src/builders/webpack", + "schema": "./src/builders/webpack/schema.json", + "description": "Build a webpack app." + }, "webpack": { - "class": "./src/webpack", - "schema": "./src/webpack/schema.json", + "implementation": "./src/builders/webpack", + "schema": "./src/builders/webpack/schema.json", "description": "Build a webpack app." }, + "dev-server": { + "implementation": "./src/builders/webpack-dev-server", + "schema": "./src/builders/webpack-dev-server/schema.json", + "description": "Serve a webpack app." + }, "webpack-dev-server": { - "class": "./src/webpack-dev-server", - "schema": "./src/webpack-dev-server/schema.json", + "implementation": "./src/builders/webpack-dev-server", + "schema": "./src/builders/webpack-dev-server/schema.json", "description": "Serve a webpack app." } } diff --git a/packages/angular_devkit/build_webpack/index.ts b/packages/angular_devkit/build_webpack/index.ts new file mode 100644 index 000000000000..e6da94cc7ded --- /dev/null +++ b/packages/angular_devkit/build_webpack/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './src/index'; diff --git a/packages/angular_devkit/build_webpack/package.json b/packages/angular_devkit/build_webpack/package.json index 0d2788e7924d..6d6dcaf4cda1 100644 --- a/packages/angular_devkit/build_webpack/package.json +++ b/packages/angular_devkit/build_webpack/package.json @@ -1,21 +1,32 @@ { "name": "@angular-devkit/build-webpack", - "version": "0.0.0", + "version": "0.0.0-EXPERIMENTAL-PLACEHOLDER", "description": "Webpack Builder for Architect", + "experimental": true, "main": "src/index.js", "typings": "src/index.d.ts", "builders": "builders.json", - "dependencies": { - "@angular-devkit/architect": "0.0.0", - "@angular-devkit/core": "0.0.0", - "rxjs": "6.3.3" + "exports": { + ".": { + "types": "./src/index.d.ts", + "default": "./src/index.js" + }, + "./package.json": "./package.json", + "./*": "./*.js", + "./*.js": "./*.js" }, - "peerDependencies": { - "webpack": "^4.6.0", - "webpack-dev-server": "^3.1.4" + "dependencies": { + "@angular-devkit/architect": "workspace:0.0.0-EXPERIMENTAL-PLACEHOLDER", + "rxjs": "7.8.2" }, "devDependencies": { - "webpack": "^4.6.0", - "webpack-dev-server": "^3.1.4" + "@angular-devkit/core": "workspace:0.0.0-PLACEHOLDER", + "@ngtools/webpack": "workspace:0.0.0-PLACEHOLDER", + "webpack": "5.112.2", + "webpack-dev-server": "6.1.0" + }, + "peerDependencies": { + "webpack": "^5.30.0", + "webpack-dev-server": "^5.0.2 || ^6.0.0" } -} \ No newline at end of file +} diff --git a/packages/angular_devkit/build_webpack/src/builders/webpack-dev-server/index.ts b/packages/angular_devkit/build_webpack/src/builders/webpack-dev-server/index.ts new file mode 100644 index 000000000000..c370efc1ec37 --- /dev/null +++ b/packages/angular_devkit/build_webpack/src/builders/webpack-dev-server/index.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Builder, BuilderContext, createBuilder } from '@angular-devkit/architect'; +import assert from 'node:assert'; +import { resolve as pathResolve } from 'node:path'; +import { Observable, from, isObservable, of, switchMap } from 'rxjs'; +import type webpack from 'webpack'; +import type WebpackDevServer from 'webpack-dev-server'; +import type { Configuration } from 'webpack-dev-server'; +import { getEmittedFiles, getWebpackConfig } from '../../utils'; +import { BuildResult, WebpackFactory, WebpackLoggingCallback } from '../webpack'; +import { Schema as WebpackDevServerBuilderSchema } from './schema'; + +export type WebpackDevServerFactory = typeof WebpackDevServer; + +export type DevServerBuildOutput = BuildResult & { + port: number; + family: string; + address: string; +}; + +/** + * @deprecated Part of Angular's Webpack support deprecation. Use `@angular/build` APIs instead. + * Deprecated since v22. + */ +export function runWebpackDevServer( + config: webpack.Configuration, + context: BuilderContext, + options: { + shouldProvideStats?: boolean; + devServerConfig?: Configuration; + logging?: WebpackLoggingCallback; + webpackFactory?: WebpackFactory; + webpackDevServerFactory?: WebpackDevServerFactory; + } = {}, +): Observable { + const createWebpack = (c: webpack.Configuration) => { + if (options.webpackFactory) { + const result = options.webpackFactory(c); + if (isObservable(result)) { + return result; + } else { + return of(result); + } + } else { + return from(import('webpack').then((mod) => mod.default(c))); + } + }; + + const { + logging: log = (stats, config) => { + if (config.stats !== false) { + const statsOptions = config.stats === true ? undefined : config.stats; + context.logger.info(stats.toString(statsOptions)); + } + }, + shouldProvideStats = true, + } = options; + + return createWebpack({ ...config, watch: false }).pipe( + switchMap(async (webpackCompiler) => { + return [ + webpackCompiler, + options.webpackDevServerFactory ?? (await import('webpack-dev-server')).default, + ] as unknown as [webpack.Compiler | null, WebpackDevServerFactory]; + }), + switchMap( + ([webpackCompiler, webpackDevServerFactory]) => + new Observable((obs) => { + assert(webpackCompiler, 'Webpack compiler factory did not return a compiler instance.'); + + const devServerConfig = options.devServerConfig || config.devServer || {}; + devServerConfig.host ??= 'localhost'; + + let result: Partial; + const statsOptions = typeof config.stats === 'boolean' ? undefined : config.stats; + + webpackCompiler.hooks.done.tap('build-webpack', (stats) => { + // Log stats. + log(stats, config); + obs.next({ + ...result, + webpackStats: shouldProvideStats ? stats.toJson(statsOptions) : undefined, + emittedFiles: getEmittedFiles(stats.compilation), + success: !stats.hasErrors(), + outputPath: stats.compilation.outputOptions.path, + } as unknown as DevServerBuildOutput); + }); + + const devServer = new webpackDevServerFactory(devServerConfig, webpackCompiler); + devServer.startCallback((err) => { + if (err) { + obs.error(err); + + return; + } + + const address = devServer.server?.address(); + if (!address) { + obs.error(new Error(`Dev-server address info is not defined.`)); + + return; + } + + result = { + success: true, + port: typeof address === 'string' ? 0 : address.port, + family: typeof address === 'string' ? '' : address.family, + address: typeof address === 'string' ? address : address.address, + }; + }); + + // Teardown logic. Close the server when unsubscribed from. + return () => { + devServer.stopCallback(() => {}); + webpackCompiler.close(() => {}); + }; + }), + ), + ); +} + +const builder: Builder = createBuilder< + WebpackDevServerBuilderSchema, + DevServerBuildOutput +>((options, context) => { + context.logger.warn( + 'The "@angular-devkit/build-webpack:webpack-dev-server" builder is deprecated as part of Angular\'s Webpack support deprecation. ' + + 'Use "@angular/build" instead.', + ); + + const configPath = pathResolve(context.workspaceRoot, options.webpackConfig); + + return from(getWebpackConfig(configPath)).pipe( + switchMap((config) => runWebpackDevServer(config, context)), + ); +}); + +export default builder; diff --git a/packages/angular_devkit/build_webpack/src/builders/webpack-dev-server/index_spec.ts b/packages/angular_devkit/build_webpack/src/builders/webpack-dev-server/index_spec.ts new file mode 100644 index 000000000000..9e4f688d0c4e --- /dev/null +++ b/packages/angular_devkit/build_webpack/src/builders/webpack-dev-server/index_spec.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { WorkspaceNodeModulesArchitectHost } from '@angular-devkit/architect/node'; +import { TestingArchitectHost } from '@angular-devkit/architect/testing'; +import { schema, workspaces } from '@angular-devkit/core'; +import { NodeJsSyncHost } from '@angular-devkit/core/node'; +import * as path from 'node:path'; +import { DevServerBuildOutput } from './index'; + +describe('Dev Server Builder', () => { + let testArchitectHost: TestingArchitectHost; + let architect: Architect; + let vfHost: NodeJsSyncHost; + + const webpackTargetSpec = { project: 'app', target: 'serve' }; + + async function createArchitect(workspaceRoot: string) { + vfHost = new NodeJsSyncHost(); + + const registry = new schema.CoreSchemaRegistry(); + registry.addPostTransform(schema.transforms.addUndefinedDefaults); + + const { workspace } = await workspaces.readWorkspace( + workspaceRoot, + workspaces.createWorkspaceHost(vfHost), + ); + + testArchitectHost = new TestingArchitectHost( + workspaceRoot, + workspaceRoot, + new WorkspaceNodeModulesArchitectHost(workspace, workspaceRoot), + ); + architect = new Architect(testArchitectHost, registry); + } + + beforeEach(async () => { + const ngJsonPath = path.join(__dirname, '../../../test/basic-app/angular.json'); + const workspaceRoot = path.dirname(require.resolve(ngJsonPath)); + await createArchitect(workspaceRoot); + }); + + it('works with CJS config', async () => { + const run = await architect.scheduleTarget(webpackTargetSpec, { + webpackConfig: 'webpack.config.cjs', + }); + const output = await run.result; + expect(output.success).toBe(true); + + const response = await fetch(`http://localhost:${output.port}/bundle.js`); + expect(await response.text()).toContain(`console.log('hello world')`); + + await run.stop(); + }, 30000); + + it('works with ESM config', async () => { + const run = await architect.scheduleTarget(webpackTargetSpec, { + webpackConfig: 'webpack.config.mjs', + }); + const output = await run.result; + expect(output.success).toBe(true); + + const response = await fetch(`http://localhost:${output.port}/bundle.js`); + expect(await response.text()).toContain(`console.log('hello world')`); + + await run.stop(); + }, 30000); + + it('works and returns emitted files', async () => { + const run = await architect.scheduleTarget(webpackTargetSpec); + const output = await run.result; + + expect(output.success).toBe(true); + expect(output.emittedFiles).toContain({ + id: 'main', + name: 'main', + initial: true, + file: 'bundle.js', + extension: '.js', + }); + await run.stop(); + }, 30000); +}); diff --git a/packages/angular_devkit/build_webpack/src/builders/webpack-dev-server/schema.json b/packages/angular_devkit/build_webpack/src/builders/webpack-dev-server/schema.json new file mode 100644 index 000000000000..6a1e18fd92e5 --- /dev/null +++ b/packages/angular_devkit/build_webpack/src/builders/webpack-dev-server/schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Webpack Dev-Server Builder", + "description": "Webpack Dev-Server Builder schema for Architect.", + "type": "object", + "properties": { + "webpackConfig": { + "type": "string", + "description": "The path to the Webpack configuration file." + } + }, + "additionalProperties": false, + "required": ["webpackConfig"] +} diff --git a/packages/angular_devkit/build_webpack/src/builders/webpack/index.ts b/packages/angular_devkit/build_webpack/src/builders/webpack/index.ts new file mode 100644 index 000000000000..2a97a075e78f --- /dev/null +++ b/packages/angular_devkit/build_webpack/src/builders/webpack/index.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Builder, BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect'; +import assert from 'node:assert'; +import { resolve as pathResolve } from 'node:path'; +import { Observable, from, isObservable, of, switchMap } from 'rxjs'; +import type webpack from 'webpack'; +import { EmittedFiles, getEmittedFiles, getWebpackConfig } from '../../utils'; +import { Schema as RealWebpackBuilderSchema } from './schema'; + +export type WebpackBuilderSchema = RealWebpackBuilderSchema; + +export interface WebpackLoggingCallback { + (stats: webpack.Stats, config: webpack.Configuration): void; +} +export interface WebpackFactory { + (config: webpack.Configuration): Observable | webpack.Compiler | null; +} + +export type BuildResult = BuilderOutput & { + emittedFiles?: EmittedFiles[]; + webpackStats?: webpack.StatsCompilation; + outputPath: string; +}; + +/** + * @deprecated Part of Angular's Webpack support deprecation. Use `@angular/build` APIs instead. + * Deprecated since v22. + */ +export function runWebpack( + config: webpack.Configuration, + context: BuilderContext, + options: { + logging?: WebpackLoggingCallback; + webpackFactory?: WebpackFactory; + shouldProvideStats?: boolean; + } = {}, +): Observable { + const { + logging: log = (stats, config) => { + if (config.stats !== false) { + const statsOptions = config.stats === true ? undefined : config.stats; + context.logger.info(stats.toString(statsOptions)); + } + }, + shouldProvideStats = true, + } = options; + + const createWebpack = (c: webpack.Configuration) => { + if (options.webpackFactory) { + const result = options.webpackFactory(c); + if (isObservable(result)) { + return result; + } else { + return of(result); + } + } else { + return from(import('webpack').then((mod) => mod.default(c))); + } + }; + + return createWebpack({ ...config, watch: false }).pipe( + switchMap( + (webpackCompiler) => + new Observable((obs) => { + assert(webpackCompiler, 'Webpack compiler factory did not return a compiler instance.'); + + const callback = (err?: Error | null, stats?: webpack.Stats) => { + if (err) { + return obs.error(err); + } + + if (!stats) { + return; + } + + // Log stats. + log(stats, config); + + const statsOptions = typeof config.stats === 'boolean' ? undefined : config.stats; + const result = { + success: !stats.hasErrors(), + webpackStats: shouldProvideStats ? stats.toJson(statsOptions) : undefined, + emittedFiles: getEmittedFiles(stats.compilation), + outputPath: stats.compilation.outputOptions.path, + } as unknown as BuildResult; + + if (config.watch) { + obs.next(result); + } else { + webpackCompiler.close(() => { + obs.next(result); + obs.complete(); + }); + } + }; + + try { + if (config.watch) { + const watchOptions = config.watchOptions || {}; + const watching = webpackCompiler.watch(watchOptions, callback); + + // Teardown logic. Close the watcher when unsubscribed from. + return () => { + watching?.close(() => {}); + webpackCompiler.close(() => {}); + }; + } else { + webpackCompiler.run(callback); + } + } catch (err) { + if (err) { + context.logger.error( + `\nAn error occurred during the build:\n${err instanceof Error ? err.stack : err}`, + ); + } + throw err; + } + }), + ), + ); +} + +const builder: Builder = createBuilder( + (options, context) => { + context.logger.warn( + 'The "@angular-devkit/build-webpack:webpack" builder is deprecated as part of Angular\'s Webpack support deprecation. ' + + 'Use "@angular/build" instead.', + ); + + const configPath = pathResolve(context.workspaceRoot, options.webpackConfig); + + return from(getWebpackConfig(configPath)).pipe( + switchMap((config) => runWebpack(config, context)), + ); + }, +); + +export default builder; diff --git a/packages/angular_devkit/build_webpack/src/builders/webpack/index_spec.ts b/packages/angular_devkit/build_webpack/src/builders/webpack/index_spec.ts new file mode 100644 index 000000000000..3632f6858e77 --- /dev/null +++ b/packages/angular_devkit/build_webpack/src/builders/webpack/index_spec.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { WorkspaceNodeModulesArchitectHost } from '@angular-devkit/architect/node'; +import { TestingArchitectHost } from '@angular-devkit/architect/testing'; +import { join, normalize, schema, workspaces } from '@angular-devkit/core'; +import { NodeJsSyncHost } from '@angular-devkit/core/node'; +import * as path from 'node:path'; + +describe('Webpack Builder basic test', () => { + let testArchitectHost: TestingArchitectHost; + let architect: Architect; + let vfHost: NodeJsSyncHost; + + async function createArchitect(workspaceRoot: string) { + vfHost = new NodeJsSyncHost(); + + const registry = new schema.CoreSchemaRegistry(); + registry.addPostTransform(schema.transforms.addUndefinedDefaults); + + const { workspace } = await workspaces.readWorkspace( + workspaceRoot, + workspaces.createWorkspaceHost(vfHost), + ); + + testArchitectHost = new TestingArchitectHost( + workspaceRoot, + workspaceRoot, + new WorkspaceNodeModulesArchitectHost(workspace, workspaceRoot), + ); + architect = new Architect(testArchitectHost, registry); + } + + describe('basic app', () => { + const ngJsonPath = path.join(__dirname, '../../../test/basic-app/angular.json'); + const workspaceRoot = path.dirname(require.resolve(ngJsonPath)); + const outputPath = join(normalize(workspaceRoot), 'dist'); + + beforeEach(async () => { + await createArchitect(workspaceRoot); + }); + + it('works with CJS config', async () => { + const run = await architect.scheduleTarget( + { project: 'app', target: 'build' }, + { webpackConfig: 'webpack.config.cjs' }, + ); + const output = await run.result; + + expect(output.success).toBe(true); + expect(await vfHost.exists(join(outputPath, 'bundle.js')).toPromise()).toBe(true); + await run.stop(); + }); + + it('works with ESM config', async () => { + const run = await architect.scheduleTarget( + { project: 'app', target: 'build' }, + { webpackConfig: 'webpack.config.mjs' }, + ); + const output = await run.result; + + expect(output.success).toBe(true); + expect(await vfHost.exists(join(outputPath, 'bundle.js')).toPromise()).toBe(true); + await run.stop(); + }); + + it('works and returns emitted files', async () => { + const run = await architect.scheduleTarget({ project: 'app', target: 'build' }); + const output = await run.result; + + expect(output.success).toBe(true); + expect(output.emittedFiles).toContain({ + id: 'main', + name: 'main', + initial: true, + file: 'bundle.js', + extension: '.js', + }); + + await run.stop(); + }); + }); + + describe('Angular app', () => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 150000; + const ngJsonPath = path.join(__dirname, '../../../test/angular-app/angular.json'); + const workspaceRoot = path.dirname(require.resolve(ngJsonPath)); + const outputPath = join(normalize(workspaceRoot), 'dist'); + + beforeEach(async () => { + await createArchitect(workspaceRoot); + }); + + it('works', async () => { + const run = await architect.scheduleTarget({ project: 'app', target: 'build-webpack' }); + const output = await run.result; + + expect(output.success).toBe(true); + expect(await vfHost.exists(join(outputPath, 'main.js')).toPromise()).toBe(true); + expect(await vfHost.exists(join(outputPath, 'polyfills.js')).toPromise()).toBe(true); + await run.stop(); + }); + + it('works and returns emitted files', async () => { + const run = await architect.scheduleTarget({ project: 'app', target: 'build-webpack' }); + const output = await run.result; + + expect(output.success).toBe(true); + expect(output.emittedFiles).toContain( + { id: 'main', name: 'main', initial: true, file: 'main.js', extension: '.js' }, + { + id: 'polyfills', + name: 'polyfills', + initial: true, + file: 'polyfills.js', + extension: '.js', + }, + ); + + await run.stop(); + }); + }); +}); diff --git a/packages/angular_devkit/build_webpack/src/builders/webpack/schema.json b/packages/angular_devkit/build_webpack/src/builders/webpack/schema.json new file mode 100644 index 000000000000..fc27c6082b9c --- /dev/null +++ b/packages/angular_devkit/build_webpack/src/builders/webpack/schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Webpack Builder.", + "description": "Webpack Builder schema for Architect.", + "type": "object", + "properties": { + "webpackConfig": { + "type": "string", + "description": "The path to the Webpack configuration file." + } + }, + "additionalProperties": false, + "required": ["webpackConfig"] +} diff --git a/packages/angular_devkit/build_webpack/src/index.ts b/packages/angular_devkit/build_webpack/src/index.ts index e73ba09edf94..e56c11d1cf24 100644 --- a/packages/angular_devkit/build_webpack/src/index.ts +++ b/packages/angular_devkit/build_webpack/src/index.ts @@ -1,10 +1,11 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -export * from './webpack'; -export * from './webpack-dev-server'; +export * from './builders/webpack'; +export * from './builders/webpack-dev-server'; +export type { EmittedFiles } from './utils'; diff --git a/packages/angular_devkit/build_webpack/src/test-utils.ts b/packages/angular_devkit/build_webpack/src/test-utils.ts deleted file mode 100644 index 5941213dd574..000000000000 --- a/packages/angular_devkit/build_webpack/src/test-utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @license - * 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 - */ - -import { TestProjectHost } from '@angular-devkit/architect/testing'; -import { join, normalize } from '@angular-devkit/core'; - - -const devkitRoot = normalize((global as any)._DevKitRoot); // tslint:disable-line:no-any -const basicWorkspaceRoot = join(devkitRoot, 'tests/angular_devkit/build_webpack/basic-app/'); -export const basicHost = new TestProjectHost(basicWorkspaceRoot); -const angularWorkspaceRoot = join(devkitRoot, 'tests/angular_devkit/build_webpack/angular-app/'); -export const angularHost = new TestProjectHost(angularWorkspaceRoot); diff --git a/packages/angular_devkit/build_webpack/src/utils.ts b/packages/angular_devkit/build_webpack/src/utils.ts new file mode 100644 index 000000000000..98a9aa04fe83 --- /dev/null +++ b/packages/angular_devkit/build_webpack/src/utils.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { existsSync } from 'node:fs'; +import * as path from 'node:path'; +import { URL, pathToFileURL } from 'node:url'; +import { Compilation, Configuration } from 'webpack'; + +export interface EmittedFiles { + id?: string; + name?: string; + file: string; + initial: boolean; + asset?: boolean; + extension: string; +} + +export function getEmittedFiles(compilation: Compilation): EmittedFiles[] { + const files: EmittedFiles[] = []; + const chunkFileNames = new Set(); + + // adds all chunks to the list of emitted files such as lazy loaded modules + for (const chunk of compilation.chunks) { + for (const file of chunk.files) { + if (chunkFileNames.has(file)) { + continue; + } + + chunkFileNames.add(file); + files.push({ + id: chunk.id?.toString(), + name: chunk.name ?? undefined, + file, + extension: path.extname(file), + initial: chunk.isOnlyInitial(), + }); + } + } + + // add all other files + for (const file of Object.keys(compilation.assets)) { + // Chunk files have already been added to the files list above + if (chunkFileNames.has(file)) { + continue; + } + + files.push({ file, extension: path.extname(file), initial: false, asset: true }); + } + + return files; +} + +export async function getWebpackConfig(configPath: string): Promise { + if (!existsSync(configPath)) { + throw new Error(`Webpack configuration file ${configPath} does not exist.`); + } + + const config = await import(configPath); + + return 'default' in config ? config.default : config; +} diff --git a/packages/angular_devkit/build_webpack/src/webpack-dev-server/index.ts b/packages/angular_devkit/build_webpack/src/webpack-dev-server/index.ts deleted file mode 100644 index 16a71eb93915..000000000000 --- a/packages/angular_devkit/build_webpack/src/webpack-dev-server/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * @license - * 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 - */ - -import { - BuildEvent, - Builder, - BuilderConfiguration, - BuilderContext, -} from '@angular-devkit/architect'; -import { Path, getSystemPath, normalize, resolve } from '@angular-devkit/core'; -import { Observable, from } from 'rxjs'; -import { concatMap } from 'rxjs/operators'; -import * as webpack from 'webpack'; -import * as WebpackDevServer from 'webpack-dev-server'; -import { LoggingCallback, defaultLoggingCb } from '../webpack'; -import { WebpackDevServerBuilderSchema } from './schema'; - - -export class WebpackDevServerBuilder implements Builder { - - constructor(public context: BuilderContext) { } - - run(builderConfig: BuilderConfiguration): Observable { - const configPath = resolve(this.context.workspace.root, - normalize(builderConfig.options.webpackConfig)); - - return this.loadWebpackConfig(getSystemPath(configPath)).pipe( - concatMap(config => this.runWebpackDevServer(config)), - ); - } - - public loadWebpackConfig(webpackConfigPath: string): Observable { - return from(import(webpackConfigPath)); - } - - public runWebpackDevServer( - webpackConfig: webpack.Configuration, - devServerCfg?: WebpackDevServer.Configuration, - loggingCb: LoggingCallback = defaultLoggingCb, - ): Observable { - return new Observable(obs => { - const devServerConfig = devServerCfg || webpackConfig.devServer || {}; - devServerConfig.host = devServerConfig.host || 'localhost'; - devServerConfig.port = devServerConfig.port || 8080; - - if (devServerConfig.stats) { - webpackConfig.stats = devServerConfig.stats as webpack.Stats.ToStringOptionsObject; - } - // Disable stats reporting by the devserver, we have our own logger. - devServerConfig.stats = false; - - const webpackCompiler = webpack(webpackConfig); - const server = new WebpackDevServer(webpackCompiler, devServerConfig); - - webpackCompiler.hooks.done.tap('build-webpack', (stats) => { - // Log stats. - loggingCb(stats, webpackConfig, this.context.logger); - - obs.next({ success: !stats.hasErrors() }); - }); - - server.listen( - devServerConfig.port, - devServerConfig.host, - (err) => { - if (err) { - obs.error(err); - } - }, - ); - - // Teardown logic. Close the server when unsubscribed from. - return () => server.close(); - }); - } -} - - -export default WebpackDevServerBuilder; diff --git a/packages/angular_devkit/build_webpack/src/webpack-dev-server/index_spec_large.ts b/packages/angular_devkit/build_webpack/src/webpack-dev-server/index_spec_large.ts deleted file mode 100644 index f2778e98d6d6..000000000000 --- a/packages/angular_devkit/build_webpack/src/webpack-dev-server/index_spec_large.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * @license - * 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 - */ - -import { request, runTargetSpec } from '@angular-devkit/architect/testing'; -import { from } from 'rxjs'; -import { concatMap, take, tap } from 'rxjs/operators'; -import { basicHost } from '../test-utils'; - - -describe('Dev Server Builder', () => { - const webpackTargetSpec = { project: 'app', target: 'serve' }; - - beforeEach(done => basicHost.initialize().toPromise().then(done, done.fail)); - afterEach(done => basicHost.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - runTargetSpec(basicHost, webpackTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - concatMap(() => from(request('http://localhost:8080/bundle.js'))), - tap(response => expect(response).toContain(`console.log('hello world')`)), - take(1), - ).toPromise().then(done, done.fail); - }, 30000); -}); diff --git a/packages/angular_devkit/build_webpack/src/webpack-dev-server/schema.d.ts b/packages/angular_devkit/build_webpack/src/webpack-dev-server/schema.d.ts deleted file mode 100644 index e9a8036076a1..000000000000 --- a/packages/angular_devkit/build_webpack/src/webpack-dev-server/schema.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * @license - * 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 - */ -export interface WebpackDevServerBuilderSchema { - /** - * The path to the Webpack configuration file. - */ - webpackConfig: string; -} diff --git a/packages/angular_devkit/build_webpack/src/webpack-dev-server/schema.json b/packages/angular_devkit/build_webpack/src/webpack-dev-server/schema.json deleted file mode 100644 index f61d0fa9b680..000000000000 --- a/packages/angular_devkit/build_webpack/src/webpack-dev-server/schema.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "title": "Webpack Dev-Server Builder", - "description": "Webpack Dev-Server Builder schema for Architect.", - "type": "object", - "properties": { - "webpackConfig": { - "type": "string", - "description": "The path to the Webpack configuration file." - } - }, - "additionalProperties": false, - "required": [ - "webpackConfig" - ] -} \ No newline at end of file diff --git a/packages/angular_devkit/build_webpack/src/webpack/index.ts b/packages/angular_devkit/build_webpack/src/webpack/index.ts deleted file mode 100644 index 9f245527b28d..000000000000 --- a/packages/angular_devkit/build_webpack/src/webpack/index.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @license - * 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 - */ -import { - BuildEvent, - Builder, - BuilderConfiguration, - BuilderContext, -} from '@angular-devkit/architect'; -import { Path, getSystemPath, logging, normalize, resolve } from '@angular-devkit/core'; -import { Observable, from } from 'rxjs'; -import { concatMap } from 'rxjs/operators'; -import * as webpack from 'webpack'; -import { WebpackBuilderSchema } from './schema'; - - -export interface LoggingCallback { - (stats: webpack.Stats, config: webpack.Configuration, logger: logging.Logger): void; -} - -export const defaultLoggingCb: LoggingCallback = (stats, config, logger) => - logger.info(stats.toString(config.stats)); - -export class WebpackBuilder implements Builder { - - constructor(public context: BuilderContext) { } - - run(builderConfig: BuilderConfiguration): Observable { - const configPath = resolve(this.context.workspace.root, - normalize(builderConfig.options.webpackConfig)); - - return this.loadWebpackConfig(getSystemPath(configPath)).pipe( - concatMap(config => this.runWebpack(config)), - ); - } - - public loadWebpackConfig(webpackConfigPath: string): Observable { - return from(import(webpackConfigPath)); - } - - public runWebpack( - config: webpack.Configuration, loggingCb = defaultLoggingCb, - ): Observable { - return new Observable(obs => { - const webpackCompiler = webpack(config); - - const callback: webpack.compiler.CompilerCallback = (err, stats) => { - if (err) { - return obs.error(err); - } - - // Log stats. - loggingCb(stats, config, this.context.logger); - - obs.next({ success: !stats.hasErrors() }); - - if (!config.watch) { - obs.complete(); - } - }; - - try { - if (config.watch) { - const watchOptions = config.watchOptions || {}; - const watching = webpackCompiler.watch(watchOptions, callback); - - // Teardown logic. Close the watcher when unsubscribed from. - return () => watching.close(() => { }); - } else { - webpackCompiler.run(callback); - } - } catch (err) { - if (err) { - this.context.logger.error( - '\nAn error occured during the build:\n' + ((err && err.stack) || err)); - } - throw err; - } - }); - } -} - -export default WebpackBuilder; diff --git a/packages/angular_devkit/build_webpack/src/webpack/index_spec_large.ts b/packages/angular_devkit/build_webpack/src/webpack/index_spec_large.ts deleted file mode 100644 index a9e130aa2afa..000000000000 --- a/packages/angular_devkit/build_webpack/src/webpack/index_spec_large.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @license - * 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 - */ - -import { runTargetSpec } from '@angular-devkit/architect/testing'; -import { join, normalize } from '@angular-devkit/core'; -import { tap } from 'rxjs/operators'; -import { angularHost, basicHost } from '../test-utils'; - - -describe('Webpack Builder basic test', () => { - describe('basic app', () => { - const outputPath = normalize('dist'); - const webpackTargetSpec = { project: 'app', target: 'build' }; - - beforeEach(done => basicHost.initialize().toPromise().then(done, done.fail)); - afterEach(done => basicHost.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - runTargetSpec(basicHost, webpackTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - expect(basicHost.scopedSync().exists(join(outputPath, 'bundle.js'))).toBe(true); - }), - ).toPromise().then(done, done.fail); - }); - }); - - describe('Angular app', () => { - const outputPath = normalize('dist/'); - const webpackTargetSpec = { project: 'app', target: 'build-webpack' }; - - beforeEach(done => angularHost.initialize().toPromise().then(done, done.fail)); - afterEach(done => angularHost.restore().toPromise().then(done, done.fail)); - - it('works', (done) => { - runTargetSpec(angularHost, webpackTargetSpec).pipe( - tap((buildEvent) => expect(buildEvent.success).toBe(true)), - tap(() => { - expect(angularHost.scopedSync().exists(join(outputPath, 'main.js'))).toBe(true); - expect(angularHost.scopedSync().exists(join(outputPath, 'polyfills.js'))).toBe(true); - }), - ).toPromise().then(done, done.fail); - }); - }); -}); diff --git a/packages/angular_devkit/build_webpack/src/webpack/schema.d.ts b/packages/angular_devkit/build_webpack/src/webpack/schema.d.ts deleted file mode 100644 index 46839407eea9..000000000000 --- a/packages/angular_devkit/build_webpack/src/webpack/schema.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * @license - * 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 - */ -export interface WebpackBuilderSchema { - /** - * The path to the Webpack configuration file. - */ - webpackConfig: string; -} diff --git a/packages/angular_devkit/build_webpack/src/webpack/schema.json b/packages/angular_devkit/build_webpack/src/webpack/schema.json deleted file mode 100644 index bd29228a59c3..000000000000 --- a/packages/angular_devkit/build_webpack/src/webpack/schema.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "title": "Webpack Builder.", - "description": "Webpack Builder schema for Architect.", - "properties": { - "webpackConfig": { - "type": "string", - "description": "The path to the Webpack configuration file." - } - }, - "additionalProperties": false, - "required": [ - "webpackConfig" - ] -} \ No newline at end of file diff --git a/packages/angular_devkit/build_webpack/test/angular-app/angular.json b/packages/angular_devkit/build_webpack/test/angular-app/angular.json new file mode 100644 index 000000000000..3bb325a1d073 --- /dev/null +++ b/packages/angular_devkit/build_webpack/test/angular-app/angular.json @@ -0,0 +1,33 @@ +{ + "$schema": "../../../../packages/angular_devkit/core/src/workspace/workspace-schema.json", + "version": 1, + "newProjectRoot": "./projects", + "cli": { + "cache": { + "enabled": false + } + }, + "schematics": {}, + "projects": { + "app": { + "root": "src", + "sourceRoot": "src", + "projectType": "application", + "schematics": {}, + "targets": { + "build-webpack": { + "builder": "../../:webpack", + "options": { + "webpackConfig": "webpack.config.js" + } + }, + "serve-webpack": { + "builder": "../../:webpack-dev-server", + "options": { + "webpackConfig": "webpack.config.js" + } + } + } + } + } +} diff --git a/tests/angular_devkit/build_webpack/angular-app/src/app/app.component.css b/packages/angular_devkit/build_webpack/test/angular-app/src/app/app.component.css similarity index 100% rename from tests/angular_devkit/build_webpack/angular-app/src/app/app.component.css rename to packages/angular_devkit/build_webpack/test/angular-app/src/app/app.component.css diff --git a/packages/angular_devkit/build_webpack/test/angular-app/src/app/app.component.html b/packages/angular_devkit/build_webpack/test/angular-app/src/app/app.component.html new file mode 100644 index 000000000000..bfa311626596 --- /dev/null +++ b/packages/angular_devkit/build_webpack/test/angular-app/src/app/app.component.html @@ -0,0 +1,27 @@ + +
+

Welcome to {{ title }}!

+ Angular Logo +
+

Here are some links to help you start:

+ + +

i18n test

diff --git a/packages/angular_devkit/build_webpack/test/angular-app/src/app/app.component.spec.ts b/packages/angular_devkit/build_webpack/test/angular-app/src/app/app.component.spec.ts new file mode 100644 index 000000000000..c57b4250561f --- /dev/null +++ b/packages/angular_devkit/build_webpack/test/angular-app/src/app/app.component.spec.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { TestBed } from '@angular/core/testing'; +import { AppComponent } from './app.component'; +describe('AppComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [AppComponent], + }); + }); + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.debugElement.componentInstance; + expect(app).toBeTruthy(); + }); + it(`should have as title 'app'`, () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.debugElement.componentInstance; + expect(app.title).toEqual('app'); + }); + it('should render title in a h1 tag', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.debugElement.nativeElement; + expect(compiled.querySelector('h1').textContent).toContain('Welcome to app!'); + }); +}); diff --git a/packages/angular_devkit/build_webpack/test/angular-app/src/app/app.component.ts b/packages/angular_devkit/build_webpack/test/angular-app/src/app/app.component.ts new file mode 100644 index 000000000000..93a041e8aec7 --- /dev/null +++ b/packages/angular_devkit/build_webpack/test/angular-app/src/app/app.component.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-root', + standalone: false, + templateUrl: './app.component.html', + styleUrls: ['./app.component.css'], +}) +export class AppComponent { + title = 'app'; +} diff --git a/packages/angular_devkit/build_webpack/test/angular-app/src/app/app.module.ts b/packages/angular_devkit/build_webpack/test/angular-app/src/app/app.module.ts new file mode 100644 index 000000000000..3c42f0048330 --- /dev/null +++ b/packages/angular_devkit/build_webpack/test/angular-app/src/app/app.module.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BrowserModule } from '@angular/platform-browser'; +import { NgModule } from '@angular/core'; + +import { AppComponent } from './app.component'; + +@NgModule({ + declarations: [AppComponent], + imports: [BrowserModule], + providers: [], + bootstrap: [AppComponent], +}) +export class AppModule {} diff --git a/tests/angular_devkit/build_webpack/angular-app/src/assets/.gitkeep b/packages/angular_devkit/build_webpack/test/angular-app/src/assets/.gitkeep similarity index 100% rename from tests/angular_devkit/build_webpack/angular-app/src/assets/.gitkeep rename to packages/angular_devkit/build_webpack/test/angular-app/src/assets/.gitkeep diff --git a/packages/angular_devkit/build_webpack/test/angular-app/src/main.ts b/packages/angular_devkit/build_webpack/test/angular-app/src/main.ts new file mode 100644 index 000000000000..9fff4140458d --- /dev/null +++ b/packages/angular_devkit/build_webpack/test/angular-app/src/main.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { platformBrowser } from '@angular/platform-browser'; +import { AppModule } from './app/app.module'; + +platformBrowser() + .bootstrapModule(AppModule) + .catch((err) => console.log(err)); diff --git a/packages/angular_devkit/build_webpack/test/angular-app/src/polyfills.ts b/packages/angular_devkit/build_webpack/test/angular-app/src/polyfills.ts new file mode 100644 index 000000000000..a8b1b2c83975 --- /dev/null +++ b/packages/angular_devkit/build_webpack/test/angular-app/src/polyfills.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * This file includes polyfills needed by Angular and is loaded before the app. + * You can add your own extra polyfills to this file. + * + * This file is divided into 2 sections: + * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. + * 2. Application imports. Files imported after ZoneJS that should be loaded before your main + * file. + * + * The current setup is for so-called "evergreen" browsers; the last versions of browsers that + * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), + * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. + * + * Learn more in https://angular.dev/reference/versions#browser-support + */ + +/*************************************************************************************************** + * BROWSER POLYFILLS + */ + +/** + * Required to support Web Animations `@angular/platform-browser/animations`. + * Needed for: All but Chrome, Firefox and Opera. http://caniuse.com/#feat=web-animation + **/ +// import 'web-animations-js'; // Run `npm install --save web-animations-js`. + +/*************************************************************************************************** + * Zone JS is required by default for Angular itself. + */ +import 'zone.js'; // Included with Angular CLI. + +/*************************************************************************************************** + * APPLICATION IMPORTS + */ diff --git a/packages/angular_devkit/build_webpack/test/angular-app/src/tsconfig.app.json b/packages/angular_devkit/build_webpack/test/angular-app/src/tsconfig.app.json new file mode 100644 index 000000000000..4191001bb4d4 --- /dev/null +++ b/packages/angular_devkit/build_webpack/test/angular-app/src/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/app", + "module": "es2022", + "types": [] + }, + "exclude": ["test.ts", "**/*.spec.ts"] +} diff --git a/packages/angular_devkit/build_webpack/test/angular-app/tsconfig.json b/packages/angular_devkit/build_webpack/test/angular-app/tsconfig.json new file mode 100644 index 000000000000..beb80b8f4d01 --- /dev/null +++ b/packages/angular_devkit/build_webpack/test/angular-app/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "sourceMap": true, + "declaration": false, + "moduleResolution": "bundler", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "target": "es2022", + "lib": ["es2022", "dom"] + }, + "angularCompilerOptions": { + "enableIvy": true, + "disableTypeScriptVersionCheck": true + } +} diff --git a/packages/angular_devkit/build_webpack/test/angular-app/webpack.config.js b/packages/angular_devkit/build_webpack/test/angular-app/webpack.config.js new file mode 100644 index 000000000000..ed885f54ad43 --- /dev/null +++ b/packages/angular_devkit/build_webpack/test/angular-app/webpack.config.js @@ -0,0 +1,39 @@ +const ngToolsWebpack = require('@ngtools/webpack'); +const path = require('path'); + +const workspaceRoot = path.resolve(__dirname, './'); +const projectRoot = path.resolve(__dirname, './'); + +module.exports = { + mode: 'development', + resolve: { + extensions: ['.ts', '.js'], + }, + entry: { + main: path.resolve(projectRoot, './src/main.ts'), + polyfills: path.resolve(projectRoot, './src/polyfills.ts'), + }, + output: { + path: path.resolve(workspaceRoot, './dist'), + filename: `[name].js`, + }, + plugins: [ + new ngToolsWebpack.AngularWebpackPlugin({ + tsconfig: path.resolve(projectRoot, './src/tsconfig.app.json'), + }), + ], + module: { + rules: [ + // rxjs 6 requires directory imports which are not support in ES modules. + // Disabling `fullySpecified` allows Webpack to ignore this but this is + // not ideal because it currently disables ESM behavior import for all JS files. + { test: /\.[m]?js$/, resolve: { fullySpecified: false } }, + { test: /\.css$/, type: 'asset/source' }, + { test: /\.html$/, type: 'asset/source' }, + { test: /\.ts$/, loader: '@ngtools/webpack' }, + ], + }, + devServer: { + historyApiFallback: true, + }, +}; diff --git a/packages/angular_devkit/build_webpack/test/basic-app/.gitignore b/packages/angular_devkit/build_webpack/test/basic-app/.gitignore new file mode 100644 index 000000000000..e0406551d1ea --- /dev/null +++ b/packages/angular_devkit/build_webpack/test/basic-app/.gitignore @@ -0,0 +1,43 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# compiled output +/dist +/tmp +/out-tsc + +# dependencies +/node_modules + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +/.angular/cache +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +testem.log +/typings + +# e2e +/e2e/*.js +/e2e/*.map + +# System Files +.DS_Store +Thumbs.db diff --git a/packages/angular_devkit/build_webpack/test/basic-app/angular.json b/packages/angular_devkit/build_webpack/test/basic-app/angular.json new file mode 100644 index 000000000000..5620c21f7ff8 --- /dev/null +++ b/packages/angular_devkit/build_webpack/test/basic-app/angular.json @@ -0,0 +1,29 @@ +{ + "$schema": "../../../core/src/workspace/workspace-schema.json", + "version": 1, + "newProjectRoot": "./projects", + "cli": {}, + "schematics": {}, + "projects": { + "app": { + "root": "src", + "sourceRoot": "src", + "projectType": "application", + "schematics": {}, + "targets": { + "build": { + "builder": "../../:webpack", + "options": { + "webpackConfig": "webpack.config.cjs" + } + }, + "serve": { + "builder": "../../:webpack-dev-server", + "options": { + "webpackConfig": "webpack.config.cjs" + } + } + } + } + } +} diff --git a/packages/angular_devkit/build_webpack/test/basic-app/src/main.js b/packages/angular_devkit/build_webpack/test/basic-app/src/main.js new file mode 100644 index 000000000000..6be02374db11 --- /dev/null +++ b/packages/angular_devkit/build_webpack/test/basic-app/src/main.js @@ -0,0 +1 @@ +console.log('hello world'); diff --git a/packages/angular_devkit/build_webpack/test/basic-app/tsconfig.json b/packages/angular_devkit/build_webpack/test/basic-app/tsconfig.json new file mode 100644 index 000000000000..d93948c96ff0 --- /dev/null +++ b/packages/angular_devkit/build_webpack/test/basic-app/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "sourceMap": true, + "declaration": false, + "moduleResolution": "bundler", + "experimentalDecorators": true, + "target": "es2022", + "module": "esnext", + "lib": ["es2022", "dom"] + }, + "angularCompilerOptions": { + "disableTypeScriptVersionCheck": true + } +} diff --git a/packages/angular_devkit/build_webpack/test/basic-app/webpack.config.cjs b/packages/angular_devkit/build_webpack/test/basic-app/webpack.config.cjs new file mode 100644 index 000000000000..b1f988ab2071 --- /dev/null +++ b/packages/angular_devkit/build_webpack/test/basic-app/webpack.config.cjs @@ -0,0 +1,18 @@ +const path = require('path'); + +module.exports = { + mode: 'development', + entry: path.resolve(__dirname, './src/main.js'), + module: { + rules: [ + // rxjs 6 requires directory imports which are not support in ES modules. + // Disabling `fullySpecified` allows Webpack to ignore this but this is + // not ideal because it currently disables ESM behavior import for all JS files. + { test: /\.[m]?js$/, resolve: { fullySpecified: false } }, + ], + }, + output: { + path: path.resolve(__dirname, './dist'), + filename: 'bundle.js', + }, +}; diff --git a/packages/angular_devkit/build_webpack/test/basic-app/webpack.config.mjs b/packages/angular_devkit/build_webpack/test/basic-app/webpack.config.mjs new file mode 100644 index 000000000000..289fff7bdb69 --- /dev/null +++ b/packages/angular_devkit/build_webpack/test/basic-app/webpack.config.mjs @@ -0,0 +1,19 @@ +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; + +export default { + mode: 'development', + entry: resolve(fileURLToPath(import.meta.url), '../src/main.js'), + module: { + rules: [ + // rxjs 6 requires directory imports which are not support in ES modules. + // Disabling `fullySpecified` allows Webpack to ignore this but this is + // not ideal because it currently disables ESM behavior import for all JS files. + { test: /\.[m]?js$/, resolve: { fullySpecified: false } }, + ], + }, + output: { + path: resolve(fileURLToPath(import.meta.url), '../dist'), + filename: 'bundle.js', + }, +}; diff --git a/packages/angular_devkit/core/BUILD b/packages/angular_devkit/core/BUILD deleted file mode 100644 index 770dcf3adc77..000000000000 --- a/packages/angular_devkit/core/BUILD +++ /dev/null @@ -1,135 +0,0 @@ -# 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 -package(default_visibility = ["//visibility:public"]) - -load("@build_bazel_rules_typescript//:defs.bzl", "ts_library") -load("@build_bazel_rules_nodejs//:defs.bzl", "jasmine_node_test") - -licenses(["notice"]) # MIT License - -# @angular-devkit/core - -ts_library( - name = "core", - srcs = glob( - include = ["src/**/*.ts"], - exclude = [ - "src/**/*_spec.ts", - "src/**/*_spec_large.ts", - "src/**/*_benchmark.ts", - ], - ), - data = glob(["**/*.json"]), - module_name = "@angular-devkit/core", - module_root = "src/index.d.ts", - deps = [ - "@rxjs", - "@rxjs//operators", - # @typings: es2015.core - # @typings: es2015.symbol.wellknown - # @typings: es2016.array.include - # @typings: ajv - # @typings: node - # @typings: source-map - ], -) - -ts_library( - name = "core_test_lib", - srcs = glob( - include = [ - "src/**/*_spec.ts", - "src/**/*_spec_large.ts", - ], - ), - deps = [ - ":core", - ":node", - "@rxjs", - "@rxjs//operators", - # @typings: jasmine - # @typings: node - ], - data = [ - "src/workspace/workspace-schema.json", - "src/workspace/test/test-workspace.json" - ], -) - -jasmine_node_test( - name = "core_test", - srcs = [":core_test_lib"], -) - - -# @angular-devkit/core/node - -ts_library( - name = "node", - srcs = glob( - include = ["node/**/*.ts"], - exclude = [ - "node/testing/**/*.ts", - "node/**/*_spec.ts", - "node/**/*_spec_large.ts", - "node/**/*_benchmark.ts", - ], - ), - module_name = "@angular-devkit/core/node", - module_root = "node/index.d.ts", - deps = [ - ":core", - "@rxjs", - "@rxjs//operators", - # @typings: node - ], -) - -ts_library( - name = "node_test_lib", - srcs = glob( - include = [ - "node/**/*_spec.ts", - "node/**/*_spec_large.ts", - ], - ), - deps = [ - ":core", - ":node", - "@rxjs", - "@rxjs//operators", - # @typings: jasmine - # @typings: node - ], -) - -jasmine_node_test( - name = "node_test", - srcs = [":node_test_lib"], -) - - -# @angular-devkit/core/node/testing - -ts_library( - name = "node_testing", - srcs = glob( - include = ["node/testing/**/*.ts"], - exclude = [ - "node/testing/**/*_spec.ts", - "node/testing/**/*_spec_large.ts", - "node/testing/**/*_benchmark.ts", - ], - ), - module_name = "@angular-devkit/core/node/testing", - module_root = "node/testing/index.d.ts", - deps = [ - ":core", - ":node", - "@rxjs", - "@rxjs//operators", - # @typings: node - ], -) \ No newline at end of file diff --git a/packages/angular_devkit/core/BUILD.bazel b/packages/angular_devkit/core/BUILD.bazel new file mode 100644 index 000000000000..d57bd782596b --- /dev/null +++ b/packages/angular_devkit/core/BUILD.bazel @@ -0,0 +1,97 @@ +load("@devinfra//bazel/api-golden:index.bzl", "api_golden_test_npm_package") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("//tools:defaults.bzl", "jasmine_test", "npm_package", "ts_project") + +# Copyright Google LLC. All Rights Reserved. +# +# Use of this source code is governed by an MIT-style license that can be +# found in the LICENSE file at https://angular.dev/license +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +npm_link_all_packages() + +RUNTIME_ASSETS = glob( + include = ["**/*.json"], + # NB: we need to exclude the nested node_modules that is laid out by yarn workspaces + exclude = [ + "node_modules/**", + "src/workspace/json/test/**", + ], +) + +ts_project( + name = "core", + srcs = glob( + include = ["src/**/*.ts"], + exclude = [ + "src/**/*_spec.ts", + ], + ) + ["index.ts"], + data = RUNTIME_ASSETS, + deps = [ + ":node_modules/ajv", + ":node_modules/ajv-formats", + ":node_modules/jsonc-parser", + ":node_modules/picomatch", + ":node_modules/rxjs", + ":node_modules/source-map", + "//:node_modules/@types/jasmine", + "//:node_modules/@types/node", + "//:node_modules/@types/picomatch", + ], +) + +# @external_begin + +ts_project( + name = "core_test_lib", + testonly = True, + srcs = glob(["src/**/*_spec.ts"]), + data = glob(["src/workspace/json/test/**/*.json"]), + deps = [ + ":core", + ":node_modules/rxjs", + "//:node_modules/@types/node", + "//packages/angular_devkit/core/node", + ], +) + +jasmine_test( + name = "test", + data = [":core_test_lib"], +) + +genrule( + name = "license", + srcs = ["//:LICENSE"], + outs = ["LICENSE"], + cmd = "cp $(execpath //:LICENSE) $@", +) + +npm_package( + name = "pkg", + tags = ["release-package"], + deps = RUNTIME_ASSETS + [ + ":README.md", + ":core", + ":license", + "//packages/angular_devkit/core/node", + "//packages/angular_devkit/core/node/testing", + ], +) + +api_golden_test_npm_package( + name = "core_api", + data = [ + ":npm_package", + "//goldens:public-api", + ], + golden_dir = "goldens/public-api/angular_devkit/core", + npm_package = "packages/angular_devkit/core/npm_package", + types = { + "//:node_modules/@types/node": "node", + }, +) +# @external_end diff --git a/packages/angular_devkit/core/README.md b/packages/angular_devkit/core/README.md index 9657238b8c32..18ebc31ca477 100644 --- a/packages/angular_devkit/core/README.md +++ b/packages/angular_devkit/core/README.md @@ -1,4 +1,5 @@ # Core + > Shared utilities for Angular DevKit. # Exception @@ -8,6 +9,7 @@ ## Schema ### SchemaValidatorResult + ``` export interface SchemaValidatorResult { success: boolean; @@ -56,4 +58,110 @@ export class CoreSchemaRegistry implements SchemaRegistry { # Utils -# Virtual FS \ No newline at end of file +# Virtual FS + +# Workspaces + +The `workspaces` namespace provides an API for interacting with the workspace file formats. +It provides an abstraction of the underlying storage format of the workspace and provides +support for both reading and writing. Currently, the only supported format is the JSON-based +format used by the Angular CLI. For this format, the API provides internal change tracking of values which +enables fine-grained updates to the underlying storage of the workspace. This allows for the +retention of existing formatting and comments. + +A workspace is defined via the following object model. Definition collection objects are specialized +Javascript `Map` objects with an additional `add` method to simplify addition and provide more localized +error checking of the newly added values. + +```ts +export interface WorkspaceDefinition { + readonly extensions: Record; + readonly projects: ProjectDefinitionCollection; +} + +export interface ProjectDefinition { + readonly extensions: Record; + readonly targets: TargetDefinitionCollection; + root: string; + prefix?: string; + sourceRoot?: string; +} + +export interface TargetDefinition { + options?: Record; + configurations?: Record | undefined>; + builder: string; +} +``` + +The API is asynchronous and has two main functions to facilitate reading, creation, and modifying +a workspace: `readWorkspace` and `writeWorkspace`. + +```ts +export enum WorkspaceFormat { + JSON, +} +``` + +```ts +export function readWorkspace( + path: string, + host: WorkspaceHost, + format?: WorkspaceFormat, +): Promise<{ workspace: WorkspaceDefinition }>; +``` + +```ts +export function writeWorkspace( + workspace: WorkspaceDefinition, + host: WorkspaceHost, + path?: string, + format?: WorkspaceFormat, +): Promise; +``` + +A `WorkspaceHost` abstracts the underlying data access methods from the functions. It provides +methods to read, write, and analyze paths. A utility function is provided to create +an instance of a `WorkspaceHost` from the Angular DevKit's virtual filesystem host abstraction. + +```ts +export interface WorkspaceHost { + readFile(path: string): Promise; + writeFile(path: string, data: string): Promise; + isDirectory(path: string): Promise; + isFile(path: string): Promise; +} + +export function createWorkspaceHost(host: virtualFs.Host): WorkspaceHost; +``` + +## Usage Example + +To demonstrate the usage of the API, the following code will show how to add a option property +to a build target for an application. + +```ts +import { NodeJsSyncHost } from '@angular-devkit/core/node'; +import { workspaces } from '@angular-devkit/core'; + +async function demonstrate() { + const host = workspaces.createWorkspaceHost(new NodeJsSyncHost()); + const { workspace } = await workspaces.readWorkspace('path/to/workspace/directory/', host); + + const project = workspace.projects.get('my-app'); + if (!project) { + throw new Error('my-app does not exist'); + } + + const buildTarget = project.targets.get('build'); + if (!buildTarget) { + throw new Error('build target does not exist'); + } + + buildTarget.options.optimization = true; + + await workspaces.writeWorkspace(workspace, host); +} + +demonstrate(); +``` diff --git a/packages/angular_devkit/core/index.ts b/packages/angular_devkit/core/index.ts new file mode 100644 index 000000000000..e6da94cc7ded --- /dev/null +++ b/packages/angular_devkit/core/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './src/index'; diff --git a/packages/angular_devkit/core/node/BUILD.bazel b/packages/angular_devkit/core/node/BUILD.bazel new file mode 100644 index 000000000000..9c6f34c3a51f --- /dev/null +++ b/packages/angular_devkit/core/node/BUILD.bazel @@ -0,0 +1,52 @@ +# Copyright Google LLC. All Rights Reserved. +# +# Use of this source code is governed by an MIT-style license that can be +# found in the LICENSE file at https://angular.dev/license + +load("//tools:defaults.bzl", "jasmine_test", "ts_project") + +licenses(["notice"]) + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "node", + srcs = glob( + include = ["**/*.ts"], + exclude = [ + "testing/**/*.ts", + "**/*_spec.ts", + ], + ), + deps = [ + "//:node_modules/@types/node", + "//packages/angular_devkit/core", + "//packages/angular_devkit/core:node_modules/chokidar", + "//packages/angular_devkit/core:node_modules/rxjs", + ], +) + +ts_project( + name = "node_test_lib", + testonly = True, + srcs = glob( + include = [ + "**/*_spec.ts", + ], + exclude = [ + "testing/**/*.ts", + ], + ), + deps = [ + ":node", + "//:node_modules/@types/jasmine", + "//:node_modules/@types/node", + "//packages/angular_devkit/core", + "//packages/angular_devkit/core:node_modules/rxjs", + ], +) + +jasmine_test( + name = "test", + data = [":node_test_lib"], +) diff --git a/packages/angular_devkit/core/node/cli-logger.ts b/packages/angular_devkit/core/node/cli-logger.ts index 0cbbd4c1059e..a5cbada271ec 100644 --- a/packages/angular_devkit/core/node/cli-logger.ts +++ b/packages/angular_devkit/core/node/cli-logger.ts @@ -1,12 +1,13 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { filter } from 'rxjs/operators'; -import { logging, terminal } from '../src'; + +import { filter } from 'rxjs'; +import { logging } from '../src'; export interface ProcessOutput { write(buffer: string | Buffer): boolean; @@ -14,38 +15,57 @@ export interface ProcessOutput { /** * A Logger that sends information to STDOUT and STDERR. + * + * @deprecated Use a custom logger implementation instead. */ export function createConsoleLogger( verbose = false, stdout: ProcessOutput = process.stdout, stderr: ProcessOutput = process.stderr, + colors?: Partial string>>, ): logging.Logger { const logger = new logging.IndentLogger('cling'); - logger - .pipe(filter(entry => (entry.level != 'debug' || verbose))) - .subscribe(entry => { - let color: (s: string) => string = x => terminal.dim(terminal.white(x)); - let output = stdout; - switch (entry.level) { - case 'info': - color = terminal.white; - break; - case 'warn': - color = terminal.yellow; - break; - case 'error': - color = terminal.red; - output = stderr; - break; - case 'fatal': - color = (x: string) => terminal.bold(terminal.red(x)); - output = stderr; - break; - } + logger.pipe(filter((entry) => entry.level !== 'debug' || verbose)).subscribe((entry) => { + const color = colors && colors[entry.level]; + let output = stdout; + + switch (entry.level) { + case 'warn': + case 'fatal': + case 'error': + output = stderr; + break; + } - output.write(color(entry.message) + '\n'); - }); + // If we do console.log(message) or process.stdout.write(message + '\n'), the process might + // stop before the whole message is written and the stream is flushed. This happens when + // streams are asynchronous. + // + // NodeJS IO streams are different depending on platform and usage. In POSIX environment, + // for example, they're asynchronous when writing to a pipe, but synchronous when writing + // to a TTY. In windows, it's the other way around. You can verify which is which with + // stream.isTTY and platform, but this is not good enough. + // In the async case, one should wait for the callback before sending more data or + // continuing the process. In our case it would be rather hard to do (but not impossible). + // + // Instead we take the easy way out and simply chunk the message and call the write + // function while the buffer drain itself asynchronously. With a smaller chunk size than + // the buffer, we are mostly certain that it works. In this case, the chunk has been picked + // as half a page size (4096/2 = 2048), minus some bytes for the color formatting. + // On POSIX it seems the buffer is 2 pages (8192), but just to be sure (could be different + // by platform). + // + // For more details, see https://nodejs.org/api/process.html#process_a_note_on_process_i_o + const chunkSize = 2000; // Small chunk. + let message = entry.message; + while (message) { + const chunk = message.slice(0, chunkSize); + message = message.slice(chunkSize); + output.write(color ? color(chunk) : chunk); + } + output.write('\n'); + }); return logger; } diff --git a/packages/angular_devkit/core/node/fs.ts b/packages/angular_devkit/core/node/fs.ts deleted file mode 100644 index f5ca187a415a..000000000000 --- a/packages/angular_devkit/core/node/fs.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @license - * 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 - */ -import * as fs from 'fs'; - - -export function isFile(filePath: string): boolean { - let stat; - try { - stat = fs.statSync(filePath); - } catch (e) { - if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) { - return false; - } - throw e; - } - - return stat.isFile() || stat.isFIFO(); -} - - -export function isDirectory(filePath: string): boolean { - let stat; - try { - stat = fs.statSync(filePath); - } catch (e) { - if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) { - return false; - } - throw e; - } - - return stat.isDirectory(); -} diff --git a/packages/angular_devkit/core/node/host.ts b/packages/angular_devkit/core/node/host.ts index aeec54bffffe..107ce003c7f0 100644 --- a/packages/angular_devkit/core/node/host.ts +++ b/packages/angular_devkit/core/node/host.ts @@ -1,177 +1,114 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { EMPTY, Observable, concat, from as observableFrom, throwError } from 'rxjs'; -import { - concatMap, - ignoreElements, - map, - mergeMap, - publish, - refCount, -} from 'rxjs/operators'; + import { - Path, - PathFragment, - dirname, - fragment, - getSystemPath, - join, - normalize, - virtualFs, -} from '../src'; - - -interface ChokidarWatcher { - new (options: {}): ChokidarWatcher; - - add(path: string): ChokidarWatcher; - on(type: 'change', cb: (path: string) => void): ChokidarWatcher; - on(type: 'add', cb: (path: string) => void): ChokidarWatcher; - on(type: 'unlink', cb: (path: string) => void): ChokidarWatcher; - - close(): void; + PathLike, + Stats, + constants, + existsSync, + promises as fsPromises, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { dirname as pathDirname } from 'node:path'; +import { Observable, map, mergeMap, from as observableFrom, publish, refCount } from 'rxjs'; +import { Path, PathFragment, dirname, fragment, getSystemPath, normalize, virtualFs } from '../src'; + +async function exists(path: PathLike): Promise { + try { + await fsPromises.access(path, constants.F_OK); + + return true; + } catch { + return false; + } } -const { FSWatcher }: { FSWatcher: ChokidarWatcher } = require('chokidar'); - - -type FsFunction0 = (cb: (err?: Error, result?: R) => void) => void; -type FsFunction1 = (p1: T1, cb: (err?: Error, result?: R) => void) => void; -type FsFunction2 - = (p1: T1, p2: T2, cb: (err?: Error, result?: R) => void) => void; - - -function _callFs(fn: FsFunction0): Observable; -function _callFs(fn: FsFunction1, p1: T1): Observable; -function _callFs(fn: FsFunction2, p1: T1, p2: T2): Observable; - -function _callFs(fn: Function, ...args: {}[]): Observable { - return new Observable(obs => { - fn(...args, (err?: Error, result?: ResultT) => { - if (err) { - obs.error(err); - } else { - obs.next(result); - obs.complete(); +// This will only be initialized if the watch() method is called. +// Otherwise chokidar appears only in type positions, and shouldn't be referenced +// in the JavaScript output. +let FSWatcher: typeof import('chokidar').FSWatcher; +function loadFSWatcher() { + if (!FSWatcher) { + try { + FSWatcher = require('chokidar').FSWatcher; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'MODULE_NOT_FOUND') { + throw new Error( + 'As of angular-devkit version 8.0, the "chokidar" package ' + + 'must be installed in order to use watch() features.', + { + cause: e, + }, + ); } - }); - }); + throw e; + } + } } - /** * An implementation of the Virtual FS using Node as the background. There are two versions; one * synchronous and one asynchronous. */ -export class NodeJsAsyncHost implements virtualFs.Host { +export class NodeJsAsyncHost implements virtualFs.Host { get capabilities(): virtualFs.HostCapabilities { return { synchronous: false }; } write(path: Path, content: virtualFs.FileBuffer): Observable { - return new Observable(obs => { - // Create folders if necessary. - const _createDir = (path: Path) => { - if (fs.existsSync(getSystemPath(path))) { - return; - } - if (dirname(path) === path) { - throw new Error(); - } - _createDir(dirname(path)); - fs.mkdirSync(getSystemPath(path)); - }; - _createDir(dirname(path)); - - _callFs( - fs.writeFile, - getSystemPath(path), - new Uint8Array(content), - ).subscribe(obs); - }); + return observableFrom(fsPromises.mkdir(getSystemPath(dirname(path)), { recursive: true })).pipe( + mergeMap(() => fsPromises.writeFile(getSystemPath(path), new Uint8Array(content))), + ); } read(path: Path): Observable { - return _callFs(fs.readFile, getSystemPath(path)).pipe( - map(buffer => new Uint8Array(buffer).buffer as virtualFs.FileBuffer), + return observableFrom(fsPromises.readFile(getSystemPath(path))).pipe( + map((buffer) => new Uint8Array(buffer).buffer), ); } delete(path: Path): Observable { - return this.isDirectory(path).pipe( - mergeMap(isDirectory => { - if (isDirectory) { - const allFiles: Path[] = []; - const allDirs: Path[] = []; - const _recurseList = (path: Path) => { - for (const fragment of fs.readdirSync(getSystemPath(path))) { - if (fs.statSync(getSystemPath(join(path, fragment))).isDirectory()) { - _recurseList(join(path, fragment)); - allDirs.push(join(path, fragment)); - } else { - allFiles.push(join(path, fragment)); - } - } - }; - _recurseList(path); - - return concat( - observableFrom(allFiles).pipe( - mergeMap(p => _callFs(fs.unlink, getSystemPath(p))), - ignoreElements(), - ), - observableFrom(allDirs).pipe( - concatMap(p => _callFs(fs.rmdir, getSystemPath(p))), - map(() => {}), - ), - ); - } else { - return _callFs(fs.unlink, getSystemPath(path)); - } - }), + return observableFrom( + fsPromises.rm(getSystemPath(path), { force: true, recursive: true, maxRetries: 3 }), ); } rename(from: Path, to: Path): Observable { - return _callFs(fs.rename, getSystemPath(from), getSystemPath(to)); + return observableFrom(fsPromises.rename(getSystemPath(from), getSystemPath(to))); } list(path: Path): Observable { - return _callFs(fs.readdir, getSystemPath(path)).pipe( - map(names => names.map(name => fragment(name))), + return observableFrom(fsPromises.readdir(getSystemPath(path))).pipe( + map((names) => names.map((name) => fragment(name))), ); } exists(path: Path): Observable { - // Exists is a special case because it cannot error. - return new Observable(obs => { - fs.exists(path, exists => { - obs.next(exists); - obs.complete(); - }); - }); + return observableFrom(exists(getSystemPath(path))); } isDirectory(path: Path): Observable { - return _callFs(fs.stat, getSystemPath(path)).pipe( - map(stat => stat.isDirectory()), - ); + return this.stat(path).pipe(map((stat) => stat.isDirectory())); } + isFile(path: Path): Observable { - return _callFs(fs.stat, getSystemPath(path)).pipe( - map(stat => stat.isDirectory()), - ); + return this.stat(path).pipe(map((stat) => stat.isFile())); } // Some hosts may not support stat. - stat(path: Path): Observable> | null { - return _callFs(fs.stat, getSystemPath(path)); + stat(path: Path): Observable> { + return observableFrom(fsPromises.stat(getSystemPath(path))); } // Some hosts may not support watching. @@ -179,25 +116,27 @@ export class NodeJsAsyncHost implements virtualFs.Host { path: Path, _options?: virtualFs.HostWatchOptions, ): Observable | null { - return new Observable(obs => { - const watcher = new FSWatcher({ persistent: true }).add(getSystemPath(path)); + return new Observable((obs) => { + loadFSWatcher(); + const watcher = new FSWatcher({ persistent: true }); + watcher.add(getSystemPath(path)); watcher - .on('change', path => { + .on('change', (path) => { obs.next({ path: normalize(path), time: new Date(), type: virtualFs.HostWatchEventType.Changed, }); }) - .on('add', path => { + .on('add', (path) => { obs.next({ path: normalize(path), time: new Date(), type: virtualFs.HostWatchEventType.Created, }); }) - .on('unlink', path => { + .on('unlink', (path) => { obs.next({ path: normalize(path), time: new Date(), @@ -205,156 +144,85 @@ export class NodeJsAsyncHost implements virtualFs.Host { }); }); - return () => watcher.close(); - }).pipe( - publish(), - refCount(), - ); + return () => { + void watcher.close(); + }; + }).pipe(publish(), refCount()); } } - /** * An implementation of the Virtual FS using Node as the backend, synchronously. */ -export class NodeJsSyncHost implements virtualFs.Host { +export class NodeJsSyncHost implements virtualFs.Host { get capabilities(): virtualFs.HostCapabilities { return { synchronous: true }; } write(path: Path, content: virtualFs.FileBuffer): Observable { - return new Observable(obs => { - // TODO: remove this try+catch when issue https://github.com/ReactiveX/rxjs/issues/3740 is - // fixed. - try { - // Create folders if necessary. - const _createDir = (path: Path) => { - if (fs.existsSync(getSystemPath(path))) { - return; - } - _createDir(dirname(path)); - fs.mkdirSync(getSystemPath(path)); - }; - _createDir(dirname(path)); - fs.writeFileSync(getSystemPath(path), new Uint8Array(content)); - - obs.next(); - obs.complete(); - } catch (err) { - obs.error(err); - } + return new Observable((obs) => { + mkdirSync(getSystemPath(dirname(path)), { recursive: true }); + writeFileSync(getSystemPath(path), new Uint8Array(content)); + obs.next(); + obs.complete(); }); } read(path: Path): Observable { - return new Observable(obs => { - // TODO: remove this try+catch when issue https://github.com/ReactiveX/rxjs/issues/3740 is - // fixed. - try { - const buffer = fs.readFileSync(getSystemPath(path)); - - obs.next(new Uint8Array(buffer).buffer as virtualFs.FileBuffer); - obs.complete(); - } catch (err) { - obs.error(err); - } + return new Observable((obs) => { + const buffer = readFileSync(getSystemPath(path)); + + obs.next(new Uint8Array(buffer).buffer); + obs.complete(); }); } delete(path: Path): Observable { - return this.isDirectory(path).pipe( - concatMap(isDir => { - // TODO: remove this try+catch when issue https://github.com/ReactiveX/rxjs/issues/3740 is - // fixed. - if (isDir) { - const dirPaths = fs.readdirSync(getSystemPath(path)); - const rmDirComplete = new Observable((obs) => { - try { - fs.rmdirSync(getSystemPath(path)); - obs.complete(); - } catch (e) { - obs.error(e); - } - }); + return new Observable((obs) => { + rmSync(getSystemPath(path), { force: true, recursive: true, maxRetries: 3 }); - return concat( - ...dirPaths.map(name => this.delete(join(path, name))), - rmDirComplete, - ); - } else { - try { - fs.unlinkSync(getSystemPath(path)); - } catch (err) { - return throwError(err); - } - - return EMPTY; - } - }), - ); + obs.complete(); + }); } rename(from: Path, to: Path): Observable { - return new Observable(obs => { - // TODO: remove this try+catch when issue https://github.com/ReactiveX/rxjs/issues/3740 is - // fixed. - try { - fs.renameSync(getSystemPath(from), getSystemPath(to)); - obs.next(); - obs.complete(); - } catch (err) { - obs.error(err); - } + return new Observable((obs) => { + const toSystemPath = getSystemPath(to); + mkdirSync(pathDirname(toSystemPath), { recursive: true }); + renameSync(getSystemPath(from), toSystemPath); + obs.next(); + obs.complete(); }); } list(path: Path): Observable { - return new Observable(obs => { - // TODO: remove this try+catch when issue https://github.com/ReactiveX/rxjs/issues/3740 is - // fixed. - try { - const names = fs.readdirSync(getSystemPath(path)); - obs.next(names.map(name => fragment(name))); - obs.complete(); - } catch (err) { - obs.error(err); - } + return new Observable((obs) => { + const names = readdirSync(getSystemPath(path)); + obs.next(names.map((name) => fragment(name))); + obs.complete(); }); } exists(path: Path): Observable { - return new Observable(obs => { - // TODO: remove this try+catch when issue https://github.com/ReactiveX/rxjs/issues/3740 is - // fixed. - try { - obs.next(fs.existsSync(getSystemPath(path))); - obs.complete(); - } catch (err) { - obs.error(err); - } + return new Observable((obs) => { + obs.next(existsSync(getSystemPath(path))); + obs.complete(); }); } isDirectory(path: Path): Observable { - // tslint:disable-next-line:no-non-null-assertion - return this.stat(path) !.pipe(map(stat => stat.isDirectory())); + return this.stat(path).pipe(map((stat) => stat.isDirectory())); } + isFile(path: Path): Observable { - // tslint:disable-next-line:no-non-null-assertion - return this.stat(path) !.pipe(map(stat => stat.isFile())); + return this.stat(path).pipe(map((stat) => stat.isFile())); } // Some hosts may not support stat. - stat(path: Path): Observable> { - return new Observable(obs => { - // TODO: remove this try+catch when issue https://github.com/ReactiveX/rxjs/issues/3740 is - // fixed. - try { - obs.next(fs.statSync(getSystemPath(path))); - obs.complete(); - } catch (err) { - obs.error(err); - } + stat(path: Path): Observable> { + return new Observable((obs) => { + obs.next(statSync(getSystemPath(path))); + obs.complete(); }); } @@ -363,26 +231,27 @@ export class NodeJsSyncHost implements virtualFs.Host { path: Path, _options?: virtualFs.HostWatchOptions, ): Observable | null { - return new Observable(obs => { - const opts = { persistent: false }; - const watcher = new FSWatcher(opts).add(getSystemPath(path)); + return new Observable((obs) => { + loadFSWatcher(); + const watcher = new FSWatcher({ persistent: false }); + watcher.add(getSystemPath(path)); watcher - .on('change', path => { + .on('change', (path) => { obs.next({ path: normalize(path), time: new Date(), type: virtualFs.HostWatchEventType.Changed, }); }) - .on('add', path => { + .on('add', (path) => { obs.next({ path: normalize(path), time: new Date(), type: virtualFs.HostWatchEventType.Created, }); }) - .on('unlink', path => { + .on('unlink', (path) => { obs.next({ path: normalize(path), time: new Date(), @@ -390,10 +259,9 @@ export class NodeJsSyncHost implements virtualFs.Host { }); }); - return () => watcher.close(); - }).pipe( - publish(), - refCount(), - ); + return () => { + void watcher.close(); + }; + }).pipe(publish(), refCount()); } } diff --git a/packages/angular_devkit/core/node/host_spec.ts b/packages/angular_devkit/core/node/host_spec.ts index 1b55218d64f9..fba147359f98 100644 --- a/packages/angular_devkit/core/node/host_spec.ts +++ b/packages/angular_devkit/core/node/host_spec.ts @@ -1,20 +1,17 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -// tslint:disable:no-any -// tslint:disable:no-non-null-assertion -// tslint:disable:no-implicit-dependencies -import { normalize, virtualFs } from '@angular-devkit/core'; -import { NodeJsAsyncHost, NodeJsSyncHost } from '@angular-devkit/core/node'; -import * as fs from 'fs'; -import { Observable, Subscription } from 'rxjs'; - -const temp = require('temp'); +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import * as fs from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { normalize, virtualFs } from '../src'; +import { NodeJsAsyncHost, NodeJsSyncHost } from './host'; // TODO: replace this with an "it()" macro that's reusable globally. let linuxOnlyIt: typeof it = it; @@ -22,90 +19,113 @@ if (process.platform.startsWith('win') || process.platform.startsWith('darwin')) linuxOnlyIt = xit; } - describe('NodeJsAsyncHost', () => { let root: string; let host: virtualFs.Host; beforeEach(() => { - root = temp.mkdirSync('core-node-spec-'); + root = fs.mkdtempSync(join(fs.realpathSync(tmpdir()), 'core-node-spec-')); host = new virtualFs.ScopedHost(new NodeJsAsyncHost(), normalize(root)); }); - afterEach(done => host.delete(normalize('/')).toPromise().then(done, done.fail)); - - linuxOnlyIt('can watch', done => { - let obs: Observable; - let subscription: Subscription; - const content = virtualFs.stringToFileBuffer('hello world'); - const content2 = virtualFs.stringToFileBuffer('hello world 2'); - const allEvents: virtualFs.HostWatchEvent[] = []; - - Promise.resolve() - .then(() => fs.mkdirSync(root + '/sub1')) - .then(() => fs.writeFileSync(root + '/sub1/file1', 'hello world')) - .then(() => { - obs = host.watch(normalize('/sub1'), { recursive: true }) !; - expect(obs).not.toBeNull(); - subscription = obs.subscribe(event => { allEvents.push(event); }); - }) - .then(() => new Promise(resolve => setTimeout(resolve, 100))) + + afterEach(async () => host.delete(normalize('/')).toPromise()); + + it('should get correct result for exists', async () => { + const filePath = normalize('not-found'); + expect(await host.exists(filePath).toPromise()).toBeFalse(); + await host.write(filePath, new TextEncoder().encode('content').buffer).toPromise(); + expect(await host.exists(filePath).toPromise()).toBeTrue(); + }); + + linuxOnlyIt( + 'can watch', + async () => { + const content = new TextEncoder().encode('hello world').buffer; + const content2 = new TextEncoder().encode('hello world 2').buffer; + const allEvents: virtualFs.HostWatchEvent[] = []; + + fs.mkdirSync(root + '/sub1'); + fs.writeFileSync(root + '/sub1/file1', 'hello world'); + + const obs = host.watch(normalize('/sub1'), { recursive: true }); + expect(obs).toBeDefined(); + const subscription = obs!.subscribe((event) => { + allEvents.push(event); + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + // Discard the events registered so far. - .then(() => allEvents.splice(0)) - .then(() => host.write(normalize('/sub1/sub2/file3'), content).toPromise()) - .then(() => host.write(normalize('/sub1/file2'), content2).toPromise()) - .then(() => host.delete(normalize('/sub1/file1')).toPromise()) - .then(() => new Promise(resolve => setTimeout(resolve, 2000))) - .then(() => { - expect(allEvents.length).toBe(3); - subscription.unsubscribe(); - }) - .then(done, done.fail); - }, 30000); -}); + allEvents.splice(0); + await host.write(normalize('/sub1/sub2/file3'), content).toPromise(); + await host.write(normalize('/sub1/file2'), content2).toPromise(); + await host.delete(normalize('/sub1/file1')).toPromise(); + await new Promise((resolve) => setTimeout(resolve, 2000)); + expect(allEvents.length).toBe(3); + subscription.unsubscribe(); + }, + 30000, + ); +}); describe('NodeJsSyncHost', () => { let root: string; let host: virtualFs.SyncDelegateHost; beforeEach(() => { - root = temp.mkdirSync('core-node-spec-'); + root = fs.mkdtempSync(join(fs.realpathSync(tmpdir()), 'core-node-spec-')); host = new virtualFs.SyncDelegateHost( - new virtualFs.ScopedHost(new NodeJsSyncHost(), normalize(root))); + new virtualFs.ScopedHost(new NodeJsSyncHost(), normalize(root)), + ); }); afterEach(() => { host.delete(normalize('/')); }); - linuxOnlyIt('can watch', done => { - let obs: Observable; - let subscription: Subscription; - const content = virtualFs.stringToFileBuffer('hello world'); - const content2 = virtualFs.stringToFileBuffer('hello world 2'); - const allEvents: virtualFs.HostWatchEvent[] = []; - - Promise.resolve() - .then(() => fs.mkdirSync(root + '/sub1')) - .then(() => fs.writeFileSync(root + '/sub1/file1', 'hello world')) - .then(() => { - obs = host.watch(normalize('/sub1'), { recursive: true })!; - expect(obs).not.toBeNull(); - subscription = obs.subscribe(event => { allEvents.push(event); }); - }) - .then(() => new Promise(resolve => setTimeout(resolve, 100))) + linuxOnlyIt( + 'can watch', + async () => { + const content = new TextEncoder().encode('hello world').buffer; + const content2 = new TextEncoder().encode('hello world 2').buffer; + const allEvents: virtualFs.HostWatchEvent[] = []; + + fs.mkdirSync(root + '/sub1'); + fs.writeFileSync(root + '/sub1/file1', 'hello world'); + + const obs = host.watch(normalize('/sub1'), { recursive: true }); + expect(obs).toBeDefined(); + const subscription = obs!.subscribe((event) => { + allEvents.push(event); + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + // Discard the events registered so far. - .then(() => allEvents.splice(0)) - .then(() => { - host.write(normalize('/sub1/sub2/file3'), content); - host.write(normalize('/sub1/file2'), content2); - host.delete(normalize('/sub1/file1')); - }) - .then(() => new Promise(resolve => setTimeout(resolve, 2000))) - .then(() => { - expect(allEvents.length).toBe(3); - subscription.unsubscribe(); - }) - .then(done, done.fail); - }, 30000); + allEvents.splice(0); + + host.write(normalize('/sub1/sub2/file3'), content); + host.write(normalize('/sub1/file2'), content2); + host.delete(normalize('/sub1/file1')); + await new Promise((resolve) => setTimeout(resolve, 2000)); + + expect(allEvents.length).toBe(3); + subscription.unsubscribe(); + }, + 30000, + ); + + linuxOnlyIt( + 'rename to a non-existing dir', + () => { + fs.mkdirSync(root + '/rename'); + fs.writeFileSync(root + '/rename/a.txt', 'hello world'); + host.rename(normalize('/rename/a.txt'), normalize('/rename/b/c/d/a.txt')); + if (fs.existsSync(root + '/rename/b/c/d/a.txt')) { + const resContent = host.read(normalize('/rename/b/c/d/a.txt')); + const content = new TextDecoder().decode(resContent); + expect(content).toEqual('hello world'); + } + }, + 30000, + ); }); diff --git a/packages/angular_devkit/core/node/index.ts b/packages/angular_devkit/core/node/index.ts index 51a3944ba3b5..ab21d04c9e30 100644 --- a/packages/angular_devkit/core/node/index.ts +++ b/packages/angular_devkit/core/node/index.ts @@ -1,14 +1,10 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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'; export * from './cli-logger'; export * from './host'; -export { ModuleNotFoundException, ResolveOptions, resolve } from './resolve'; - -export { fs }; diff --git a/packages/angular_devkit/core/node/resolve.ts b/packages/angular_devkit/core/node/resolve.ts deleted file mode 100644 index 3f07f1170016..000000000000 --- a/packages/angular_devkit/core/node/resolve.ts +++ /dev/null @@ -1,300 +0,0 @@ -/** - * @license - * 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 - */ -import * as fs from 'fs'; -import * as path from 'path'; -import { BaseException } from '../src'; -import { isFile } from './fs'; - -/** - * Exception thrown when a module could not be resolved. - */ -export class ModuleNotFoundException extends BaseException { - public readonly code: string; - - constructor(public readonly moduleName: string, public readonly basePath: string) { - super(`Could not find module ${JSON.stringify(moduleName)} from ${JSON.stringify(basePath)}.`); - this.code = 'MODULE_NOT_FOUND'; - } -} - -/** - * Returns a list of all the callers from the resolve() call. - * @returns {string[]} - * @private - */ -function _caller(): string[] { - // see https://code.google.com/p/v8/wiki/JavaScriptStackTraceApi - const error = Error as {} as { prepareStackTrace: (x: {}, stack: {}) => {} }; - const origPrepareStackTrace = error.prepareStackTrace; - error.prepareStackTrace = (_, stack) => stack; - const stack = (new Error()).stack as {} | undefined as { getFileName(): string }[] | undefined; - error.prepareStackTrace = origPrepareStackTrace; - - return stack ? stack.map(x => x.getFileName()).filter(x => !!x) : []; -} - - -/** - * Get the global directory for node_modules. This is based on NPM code itself, and may be subject - * to change, but is relatively stable. - * @returns {string} The path to node_modules itself. - * @private - */ -function _getGlobalNodeModules() { - let globalPrefix; - - if (process.env.PREFIX) { - globalPrefix = process.env.PREFIX; - } else if (process.platform === 'win32') { - // c:\node\node.exe --> prefix=c:\node\ - globalPrefix = path.dirname(process.execPath); - } else { - // /usr/local/bin/node --> prefix=/usr/local - globalPrefix = path.dirname(path.dirname(process.execPath)); - - // destdir only is respected on Unix - const destdir = process.env.DESTDIR; - if (destdir) { - globalPrefix = path.join(destdir, globalPrefix); - } - } - - return (process.platform !== 'win32') - ? path.resolve(globalPrefix || '', 'lib', 'node_modules') - : path.resolve(globalPrefix || '', 'node_modules'); -} - - -export interface ResolveOptions { - /** - * The basedir to use from which to resolve. - */ - basedir: string; - - /** - * The list of extensions to resolve. By default uses Object.keys(require.extensions). - */ - extensions?: string[]; - - /** - * An additional list of paths to look into. - */ - paths?: string[]; - - /** - * Whether or not to preserve symbolic links. If false, the actual paths pointed by - * the symbolic links will be used. This defaults to true. - */ - preserveSymlinks?: boolean; - - /** - * Whether to fallback to a global lookup if the basedir one failed. - */ - checkGlobal?: boolean; - - /** - * Whether to fallback to using the local caller's directory if the basedir failed. - */ - checkLocal?: boolean; - - /** - * Whether to only resolve and return the first package.json file found. By default, - * resolves the main field or the index of the package. - */ - resolvePackageJson?: boolean; -} - - -let _resolveHook: ((x: string, options: ResolveOptions) => string | null) | null = null; -export function setResolveHook( - hook: ((x: string, options: ResolveOptions) => string | null) | null, -) { - _resolveHook = hook; -} - - -/** - * Resolve a package using a logic similar to npm require.resolve, but with more options. - * @param x The package name to resolve. - * @param options A list of options. See documentation of those options. - * @returns {string} Path to the index to include, or if `resolvePackageJson` option was - * passed, a path to that file. - * @throws {ModuleNotFoundException} If no module with that name was found anywhere. - */ -export function resolve(x: string, options: ResolveOptions): string { - if (_resolveHook) { - const maybe = _resolveHook(x, options); - if (maybe) { - return maybe; - } - } - - const readFileSync = fs.readFileSync; - - const extensions: string[] = options.extensions || Object.keys(require.extensions); - const basePath = options.basedir; - - options.paths = options.paths || []; - - if (/^(?:\.\.?(?:\/|$)|\/|([A-Za-z]:)?[/\\])/.test(x)) { - let res = path.resolve(basePath, x); - if (x === '..' || x.slice(-1) === '/') { - res += '/'; - } - - const m = loadAsFileSync(res) || loadAsDirectorySync(res); - if (m) { - return m; - } - } else { - const n = loadNodeModulesSync(x, basePath); - if (n) { - return n; - } - } - - // Fallback to checking the local (callee) node modules. - if (options.checkLocal) { - const callers = _caller(); - for (const caller of callers) { - const localDir = path.dirname(caller); - if (localDir !== options.basedir) { - try { - return resolve(x, { - ...options, - checkLocal: false, - checkGlobal: false, - basedir: localDir, - }); - } catch (e) { - // Just swap the basePath with the original call one. - if (!(e instanceof ModuleNotFoundException)) { - throw e; - } - } - } - } - } - - // Fallback to checking the global node modules. - if (options.checkGlobal) { - const globalDir = path.dirname(_getGlobalNodeModules()); - if (globalDir !== options.basedir) { - try { - return resolve(x, { - ...options, - checkLocal: false, - checkGlobal: false, - basedir: globalDir, - }); - } catch (e) { - // Just swap the basePath with the original call one. - if (!(e instanceof ModuleNotFoundException)) { - throw e; - } - } - } - } - - throw new ModuleNotFoundException(x, basePath); - - function loadAsFileSync(x: string): string | null { - if (isFile(x)) { - return x; - } - - return extensions.map(ex => x + ex).find(f => isFile(f)) || null; - } - - function loadAsDirectorySync(x: string): string | null { - const pkgfile = path.join(x, 'package.json'); - if (isFile(pkgfile)) { - if (options.resolvePackageJson) { - return pkgfile; - } - - try { - const body = readFileSync(pkgfile, 'UTF8'); - const pkg = JSON.parse(body); - - if (pkg['main']) { - if (pkg['main'] === '.' || pkg['main'] === './') { - pkg['main'] = 'index'; - } - - const m = loadAsFileSync(path.resolve(x, pkg['main'])); - if (m) { - return m; - } - const n = loadAsDirectorySync(path.resolve(x, pkg['main'])); - if (n) { - return n; - } - } - } catch {} - } - - return loadAsFileSync(path.join(x, '/index')); - } - - function loadNodeModulesSync(x: string, start: string): string | null { - const dirs = nodeModulesPaths(start, options); - for (const dir of dirs) { - const m = loadAsFileSync(path.join(dir, '/', x)); - if (m) { - return m; - } - const n = loadAsDirectorySync(path.join(dir, '/', x)); - if (n) { - return n; - } - } - - return null; - } - - function nodeModulesPaths(start: string, opts: ResolveOptions) { - const modules = ['node_modules']; - - // ensure that `start` is an absolute path at this point, - // resolving against the process' current working directory - let absoluteStart = path.resolve(start); - - if (opts && opts.preserveSymlinks === false) { - try { - absoluteStart = fs.realpathSync(absoluteStart); - } catch (err) { - if (err.code !== 'ENOENT') { - throw err; - } - } - } - - let prefix = '/'; - if (/^([A-Za-z]:)/.test(absoluteStart)) { - prefix = ''; - } else if (/^\\\\/.test(absoluteStart)) { - prefix = '\\\\'; - } - - const paths = [absoluteStart]; - let parsed = path.parse(absoluteStart); - while (parsed.dir !== paths[paths.length - 1]) { - paths.push(parsed.dir); - parsed = path.parse(parsed.dir); - } - - const dirs = paths.reduce((dirs: string[], aPath: string) => { - return dirs.concat(modules.map(function (moduleDir) { - return path.join(prefix, aPath, moduleDir); - })); - }, []); - - return opts && opts.paths ? dirs.concat(opts.paths) : dirs; - } -} diff --git a/packages/angular_devkit/core/node/resolve_spec.ts b/packages/angular_devkit/core/node/resolve_spec.ts deleted file mode 100644 index 03bed1baa803..000000000000 --- a/packages/angular_devkit/core/node/resolve_spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable:no-any -// tslint:disable-next-line:no-implicit-dependencies -import { resolve } from '@angular-devkit/core/node'; - -describe('resolve', () => { - - it('works', () => { - const tslintRe = /[\\/]node_modules[\\/]tslint[\\/]lib[\\/]index.js$/; - expect(resolve('tslint', { basedir: __dirname })).toMatch(tslintRe); - - expect(() => resolve('npm', { basedir: '/' })).toThrow(); - - expect(() => resolve('npm', { basedir: '/', checkGlobal: true })).not.toThrow(); - }); - -}); diff --git a/packages/angular_devkit/core/node/testing/BUILD.bazel b/packages/angular_devkit/core/node/testing/BUILD.bazel new file mode 100644 index 000000000000..fb7ffe1059d5 --- /dev/null +++ b/packages/angular_devkit/core/node/testing/BUILD.bazel @@ -0,0 +1,26 @@ +load("//tools:defaults.bzl", "ts_project") + +# Copyright Google LLC. All Rights Reserved. +# +# Use of this source code is governed by an MIT-style license that can be +# found in the LICENSE file at https://angular.dev/license +licenses(["notice"]) + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "testing", + srcs = glob( + include = ["**/*.ts"], + exclude = [ + "**/*_spec.ts", + ], + ), + deps = [ + "//:node_modules/@types/jasmine", + "//:node_modules/@types/node", + "//packages/angular_devkit/core", + "//packages/angular_devkit/core:node_modules/rxjs", + "//packages/angular_devkit/core/node", + ], +) diff --git a/packages/angular_devkit/core/node/testing/index.ts b/packages/angular_devkit/core/node/testing/index.ts index ec9d6fd9e257..cecc0c08e3c6 100644 --- a/packages/angular_devkit/core/node/testing/index.ts +++ b/packages/angular_devkit/core/node/testing/index.ts @@ -1,25 +1,29 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 * as os from 'os'; -import * as path from 'path'; -import { Path, PathFragment, getSystemPath, join, normalize, virtualFs } from '../../src'; + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { Path, getSystemPath, join, normalize, virtualFs } from '../../src'; import { NodeJsSyncHost } from '../host'; /** * A Sync Scoped Host that creates a temporary directory and scope to it. */ export class TempScopedNodeJsSyncHost extends virtualFs.ScopedHost { - protected _sync: virtualFs.SyncDelegateHost; - protected _root: Path; + protected _sync?: virtualFs.SyncDelegateHost; + protected override _root: Path; constructor() { - const root = normalize(path.join(os.tmpdir(), `devkit-host-${+Date.now()}-${process.pid}`)); + const root = normalize( + path.join(os.tmpdir(), `devkit-host-${crypto.randomUUID()}-${process.pid}`), + ); fs.mkdirSync(getSystemPath(root)); super(new NodeJsSyncHost(), root); @@ -29,9 +33,10 @@ export class TempScopedNodeJsSyncHost extends virtualFs.ScopedHost { get files(): Path[] { const sync = this.sync; function _visit(p: Path): Path[] { - return sync.list(p) - .map((fragment: PathFragment) => join(p, fragment)) - .reduce((files: Path[], path: PathFragment) => { + return sync + .list(p) + .map((fragment) => join(p, fragment)) + .reduce((files, path) => { if (sync.isDirectory(path)) { return files.concat(_visit(path)); } else { @@ -43,7 +48,9 @@ export class TempScopedNodeJsSyncHost extends virtualFs.ScopedHost { return _visit(normalize('/')); } - get root() { return this._root; } + get root() { + return this._root; + } get sync() { if (!this._sync) { this._sync = new virtualFs.SyncDelegateHost(this); diff --git a/packages/angular_devkit/core/package.json b/packages/angular_devkit/core/package.json index b6ae6091af4f..2d93dde02305 100644 --- a/packages/angular_devkit/core/package.json +++ b/packages/angular_devkit/core/package.json @@ -1,20 +1,46 @@ { "name": "@angular-devkit/core", - "version": "0.0.0", + "version": "0.0.0-PLACEHOLDER", "description": "Angular DevKit - Core Utility Library", "main": "src/index.js", "typings": "src/index.d.ts", "keywords": [ "core" ], + "exports": { + ".": { + "types": "./src/index.d.ts", + "default": "./src/index.js" + }, + "./node": { + "types": "./node/index.d.ts", + "default": "./node/index.js" + }, + "./node/testing": { + "types": "./node/testing/index.d.ts", + "default": "./node/testing/index.js" + }, + "./package.json": "./package.json", + "./*": "./*.js", + "./*.js": "./*.js" + }, "dependencies": { - "ajv": "6.5.3", - "chokidar": "2.0.4", - "fast-json-stable-stringify": "2.0.0", - "rxjs": "6.3.3", - "source-map": "0.7.3" + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.7", + "rxjs": "7.8.2", + "source-map": "0.8.0" }, "devDependencies": { - "seedrandom": "^2.4.4" + "chokidar": "6.0.0" + }, + "peerDependencies": { + "chokidar": "^6.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } } } diff --git a/packages/angular_devkit/core/src/exception.ts b/packages/angular_devkit/core/src/exception.ts new file mode 100644 index 000000000000..cd55d27e8a9a --- /dev/null +++ b/packages/angular_devkit/core/src/exception.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export class BaseException extends Error { + constructor(message = '') { + super(message); + } +} + +export class UnknownException extends BaseException { + constructor(message: string) { + super(message); + } +} + +// Exceptions +export class FileDoesNotExistException extends BaseException { + constructor(path: string) { + super(`Path "${path}" does not exist.`); + } +} +export class FileAlreadyExistException extends BaseException { + constructor(path: string) { + super(`Path "${path}" already exist.`); + } +} +export class PathIsDirectoryException extends BaseException { + constructor(path: string) { + super(`Path "${path}" is a directory.`); + } +} +export class PathIsFileException extends BaseException { + constructor(path: string) { + super(`Path "${path}" is a file.`); + } +} diff --git a/packages/angular_devkit/core/src/exception/exception.ts b/packages/angular_devkit/core/src/exception/exception.ts deleted file mode 100644 index 9ad653342770..000000000000 --- a/packages/angular_devkit/core/src/exception/exception.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @license - * 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 - */ - -export class BaseException extends Error { - constructor(message = '') { - super(message); - } -} - - -export class UnknownException extends Error { - constructor(message: string) { super(message); } -} - - -// Exceptions -export class FileDoesNotExistException extends BaseException { - constructor(path: string) { super(`Path "${path}" does not exist.`); } -} -export class FileAlreadyExistException extends BaseException { - constructor(path: string) { super(`Path "${path}" already exist.`); } -} -export class PathIsDirectoryException extends BaseException { - constructor(path: string) { super(`Path "${path}" is a directory.`); } -} -export class PathIsFileException extends BaseException { - constructor(path: string) { super(`Path "${path}" is a file.`); } -} -export class ContentHasMutatedException extends BaseException { - constructor(path: string) { - super(`Content at path "${path}" has changed between the start and the end of an update.`); - } -} -export class InvalidUpdateRecordException extends BaseException { - constructor() { super(`Invalid record instance.`); } -} -export class MergeConflictException extends BaseException { - constructor(path: string) { - super(`A merge conflicted on path "${path}".`); - } -} - -export class UnimplementedException extends BaseException { - constructor() { super('This function is unimplemented.'); } -} - -export class UnsupportedPlatformException extends BaseException { - constructor() { super('This platform is not supported by this code path.'); } -} diff --git a/packages/angular_devkit/core/src/exception/index.ts b/packages/angular_devkit/core/src/exception/index.ts deleted file mode 100644 index b335f36dcb1d..000000000000 --- a/packages/angular_devkit/core/src/exception/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @license - * 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 - */ - -export * from './exception'; diff --git a/packages/angular_devkit/core/src/experimental.ts b/packages/angular_devkit/core/src/experimental.ts deleted file mode 100644 index 9e2f0199cf67..000000000000 --- a/packages/angular_devkit/core/src/experimental.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @license - * 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 - */ -import * as workspace from './workspace'; - -export { workspace }; diff --git a/packages/angular_devkit/core/src/index.ts b/packages/angular_devkit/core/src/index.ts index 874421822d83..c3242a9e3af9 100644 --- a/packages/angular_devkit/core/src/index.ts +++ b/packages/angular_devkit/core/src/index.ts @@ -1,23 +1,18 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 experimental from './experimental'; -import * as json from './json'; -import * as logging from './logger'; -import * as terminal from './terminal'; -export * from './exception/exception'; -export * from './json'; -export * from './utils'; -export * from './virtual-fs'; +import * as json from './json/index'; +import * as logging from './logger/index'; +import * as workspaces from './workspace'; -export { - experimental, - json, - logging, - terminal, -}; +export * from './exception'; +export * from './json/index'; +export * from './utils/index'; +export * from './virtual-fs/index'; + +export { json, logging, workspaces }; diff --git a/packages/angular_devkit/core/src/json/index.ts b/packages/angular_devkit/core/src/json/index.ts index 4109871211e3..e41ad50617cf 100644 --- a/packages/angular_devkit/core/src/json/index.ts +++ b/packages/angular_devkit/core/src/json/index.ts @@ -1,12 +1,12 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -export * from './interface'; -export * from './parser'; -import * as schema from './schema'; +import * as schema from './schema/index'; + +export * from './utils'; export { schema }; diff --git a/packages/angular_devkit/core/src/json/interface.ts b/packages/angular_devkit/core/src/json/interface.ts deleted file mode 100644 index 8769239695cf..000000000000 --- a/packages/angular_devkit/core/src/json/interface.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * @license - * 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 - */ -export interface Position { - readonly offset: number; - - readonly line: number; - readonly character: number; -} - - -export type JsonAstNode = JsonAstNumber - | JsonAstString - | JsonAstIdentifier - | JsonAstArray - | JsonAstObject - | JsonAstConstantFalse - | JsonAstConstantNull - | JsonAstConstantTrue; - - -export interface JsonAstNodeBase { - readonly start: Position; - readonly end: Position; - readonly text: string; - - readonly comments?: (JsonAstComment | JsonAstMultilineComment)[]; -} - - -export interface JsonAstNumber extends JsonAstNodeBase { - readonly kind: 'number'; - readonly value: number; -} - - -export interface JsonAstString extends JsonAstNodeBase { - readonly kind: 'string'; - readonly value: string; -} - - -export interface JsonAstIdentifier extends JsonAstNodeBase { - readonly kind: 'identifier'; - readonly value: string; -} - - -export interface JsonArray extends Array {} - - -export interface JsonAstArray extends JsonAstNodeBase { - readonly kind: 'array'; - readonly elements: JsonAstNode[]; - readonly value: JsonArray; -} - - -export interface JsonObject { - [prop: string]: JsonValue; -} - - -export interface JsonAstKeyValue extends JsonAstNodeBase { - readonly kind: 'keyvalue'; - readonly key: JsonAstString | JsonAstIdentifier; - readonly value: JsonAstNode; -} - - -export interface JsonAstObject extends JsonAstNodeBase { - readonly kind: 'object'; - readonly properties: JsonAstKeyValue[]; - readonly value: JsonObject; -} - - -export interface JsonAstConstantFalse extends JsonAstNodeBase { - readonly kind: 'false'; - readonly value: false; -} - - -export interface JsonAstConstantNull extends JsonAstNodeBase { - readonly kind: 'null'; - readonly value: null; -} - - -export interface JsonAstConstantTrue extends JsonAstNodeBase { - readonly kind: 'true'; - readonly value: true; -} - - -// Loose mode AST. -export interface JsonAstMultilineComment extends JsonAstNodeBase { - readonly kind: 'multicomment'; - readonly content: string; -} -export interface JsonAstComment extends JsonAstNodeBase { - readonly kind: 'comment'; - readonly content: string; -} - - -export type JsonValue = JsonAstNode['value']; - -export function isJsonObject(value: JsonValue): value is JsonObject { - return value != null && typeof value === 'object' && !Array.isArray(value); -} - -export function isJsonArray(value: JsonValue): value is JsonArray { - return Array.isArray(value); -} diff --git a/packages/angular_devkit/core/src/json/parser.ts b/packages/angular_devkit/core/src/json/parser.ts deleted file mode 100644 index b89b6ccc92dd..000000000000 --- a/packages/angular_devkit/core/src/json/parser.ts +++ /dev/null @@ -1,909 +0,0 @@ -/** - * @license - * 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 - */ -import { BaseException } from '../exception'; -import { - JsonArray, - JsonAstArray, - JsonAstComment, - JsonAstConstantFalse, - JsonAstConstantNull, - JsonAstConstantTrue, - JsonAstIdentifier, - JsonAstKeyValue, - JsonAstMultilineComment, - JsonAstNode, - JsonAstNumber, - JsonAstObject, - JsonAstString, - JsonObject, - JsonValue, - Position, -} from './interface'; - -export class JsonException extends BaseException {} - -/** - * A character was invalid in this context. - */ -export class InvalidJsonCharacterException extends JsonException { - invalidChar: string; - line: number; - character: number; - offset: number; - - constructor(context: JsonParserContext) { - const pos = context.previous; - const invalidChar = JSON.stringify(_peek(context)); - super(`Invalid JSON character: ${invalidChar} at ${pos.line}:${pos.character}.`); - - this.invalidChar = invalidChar; - this.line = pos.line; - this.offset = pos.offset; - this.character = pos.character; - } -} - - -/** - * More input was expected, but we reached the end of the stream. - */ -export class UnexpectedEndOfInputException extends JsonException { - constructor(_context: JsonParserContext) { - super(`Unexpected end of file.`); - } -} - -/** - * An error happened within a file. - */ -export class PathSpecificJsonException extends JsonException { - constructor(public path: string, public exception: JsonException) { - super(`An error happened at file path ${JSON.stringify(path)}: ${exception.message}`); - } -} - -/** - * Context passed around the parser with information about where we currently are in the parse. - */ -export interface JsonParserContext { - position: Position; - previous: Position; - readonly original: string; - readonly mode: JsonParseMode; -} - - -/** - * Peek and return the next character from the context. - * @private - */ -function _peek(context: JsonParserContext): string | undefined { - return context.original[context.position.offset]; -} - - -/** - * Move the context to the next character, including incrementing the line if necessary. - * @private - */ -function _next(context: JsonParserContext) { - context.previous = context.position; - - let {offset, line, character} = context.position; - const char = context.original[offset]; - offset++; - if (char == '\n') { - line++; - character = 0; - } else { - character++; - } - context.position = {offset, line, character}; -} - - -/** - * Read a single character from the input. If a `valid` string is passed, validate that the - * character is included in the valid string. - * @private - */ -function _token(context: JsonParserContext, valid: string): string; -function _token(context: JsonParserContext): string | undefined; -function _token(context: JsonParserContext, valid?: string): string | undefined { - const char = _peek(context); - if (valid) { - if (!char) { - throw new UnexpectedEndOfInputException(context); - } - if (valid.indexOf(char) == -1) { - throw new InvalidJsonCharacterException(context); - } - } - - // Move the position of the context to the next character. - _next(context); - - return char; -} - - -/** - * Read the exponent part of a number. The exponent part is looser for JSON than the number - * part. `str` is the string of the number itself found so far, and start the position - * where the full number started. Returns the node found. - * @private - */ -function _readExpNumber(context: JsonParserContext, - start: Position, - str: string, - comments: (JsonAstComment | JsonAstMultilineComment)[]): JsonAstNumber { - let char; - let signed = false; - - while (true) { - char = _token(context); - if (char == '+' || char == '-') { - if (signed) { - break; - } - signed = true; - str += char; - } else if (char == '0' || char == '1' || char == '2' || char == '3' || char == '4' - || char == '5' || char == '6' || char == '7' || char == '8' || char == '9') { - signed = true; - str += char; - } else { - break; - } - } - - // We're done reading this number. - context.position = context.previous; - - return { - kind: 'number', - start, - end: context.position, - text: context.original.substring(start.offset, context.position.offset), - value: Number.parseFloat(str), - comments: comments, - }; -} - - -/** - * Read the hexa part of a 0xBADCAFE hexadecimal number. - * @private - */ -function _readHexaNumber(context: JsonParserContext, - isNegative: boolean, - start: Position, - comments: (JsonAstComment | JsonAstMultilineComment)[]): JsonAstNumber { - // Read an hexadecimal number, until it's not hexadecimal. - let hexa = ''; - const valid = '0123456789abcdefABCDEF'; - - for (let ch = _peek(context); ch && valid.includes(ch); ch = _peek(context)) { - // Add it to the hexa string. - hexa += ch; - // Move the position of the context to the next character. - _next(context); - } - - const value = Number.parseInt(hexa, 16); - - // We're done reading this number. - return { - kind: 'number', - start, - end: context.position, - text: context.original.substring(start.offset, context.position.offset), - value: isNegative ? -value : value, - comments, - }; -} - -/** - * Read a number from the context. - * @private - */ -function _readNumber(context: JsonParserContext, comments = _readBlanks(context)): JsonAstNumber { - let str = ''; - let dotted = false; - const start = context.position; - - // read until `e` or end of line. - while (true) { - const char = _token(context); - - // Read tokens, one by one. - if (char == '-') { - if (str != '') { - throw new InvalidJsonCharacterException(context); - } - } else if (char == 'I' - && (str == '-' || str == '' || str == '+') - && (context.mode & JsonParseMode.NumberConstantsAllowed) != 0) { - // Infinity? - // _token(context, 'I'); Already read. - _token(context, 'n'); - _token(context, 'f'); - _token(context, 'i'); - _token(context, 'n'); - _token(context, 'i'); - _token(context, 't'); - _token(context, 'y'); - - str += 'Infinity'; - break; - } else if (char == '0') { - if (str == '0' || str == '-0') { - throw new InvalidJsonCharacterException(context); - } - } else if (char == '1' || char == '2' || char == '3' || char == '4' || char == '5' - || char == '6' || char == '7' || char == '8' || char == '9') { - if (str == '0' || str == '-0') { - throw new InvalidJsonCharacterException(context); - } - } else if (char == '+' && str == '') { - // Pass over. - } else if (char == '.') { - if (dotted) { - throw new InvalidJsonCharacterException(context); - } - dotted = true; - } else if (char == 'e' || char == 'E') { - return _readExpNumber(context, start, str + char, comments); - } else if (char == 'x' && (str == '0' || str == '-0') - && (context.mode & JsonParseMode.HexadecimalNumberAllowed) != 0) { - return _readHexaNumber(context, str == '-0', start, comments); - } else { - // We read one too many characters, so rollback the last character. - context.position = context.previous; - break; - } - - str += char; - } - - // We're done reading this number. - if (str.endsWith('.') && (context.mode & JsonParseMode.HexadecimalNumberAllowed) == 0) { - throw new InvalidJsonCharacterException(context); - } - - return { - kind: 'number', - start, - end: context.position, - text: context.original.substring(start.offset, context.position.offset), - value: Number.parseFloat(str), - comments, - }; -} - - -/** - * Read a string from the context. Takes the comments of the string or read the blanks before the - * string. - * @private - */ -function _readString(context: JsonParserContext, comments = _readBlanks(context)): JsonAstString { - const start = context.position; - - // Consume the first string delimiter. - const delim = _token(context); - if ((context.mode & JsonParseMode.SingleQuotesAllowed) == 0) { - if (delim == '\'') { - throw new InvalidJsonCharacterException(context); - } - } - - let str = ''; - while (true) { - let char = _token(context); - if (char == delim) { - return { - kind: 'string', - start, - end: context.position, - text: context.original.substring(start.offset, context.position.offset), - value: str, - comments: comments, - }; - } else if (char == '\\') { - char = _token(context); - switch (char) { - case '\\': - case '\/': - case '"': - case delim: - str += char; - break; - - case 'b': str += '\b'; break; - case 'f': str += '\f'; break; - case 'n': str += '\n'; break; - case 'r': str += '\r'; break; - case 't': str += '\t'; break; - case 'u': - const [c0] = _token(context, '0123456789abcdefABCDEF'); - const [c1] = _token(context, '0123456789abcdefABCDEF'); - const [c2] = _token(context, '0123456789abcdefABCDEF'); - const [c3] = _token(context, '0123456789abcdefABCDEF'); - str += String.fromCharCode(parseInt(c0 + c1 + c2 + c3, 16)); - break; - - case undefined: - throw new UnexpectedEndOfInputException(context); - - case '\n': - // Only valid when multiline strings are allowed. - if ((context.mode & JsonParseMode.MultiLineStringAllowed) == 0) { - throw new InvalidJsonCharacterException(context); - } - str += char; - break; - - default: - throw new InvalidJsonCharacterException(context); - } - } else if (char === undefined) { - throw new UnexpectedEndOfInputException(context); - } else if (char == '\b' || char == '\f' || char == '\n' || char == '\r' || char == '\t') { - throw new InvalidJsonCharacterException(context); - } else { - str += char; - } - } -} - - -/** - * Read the constant `true` from the context. - * @private - */ -function _readTrue(context: JsonParserContext, - comments = _readBlanks(context)): JsonAstConstantTrue { - const start = context.position; - _token(context, 't'); - _token(context, 'r'); - _token(context, 'u'); - _token(context, 'e'); - - const end = context.position; - - return { - kind: 'true', - start, - end, - text: context.original.substring(start.offset, end.offset), - value: true, - comments, - }; -} - - -/** - * Read the constant `false` from the context. - * @private - */ -function _readFalse(context: JsonParserContext, - comments = _readBlanks(context)): JsonAstConstantFalse { - const start = context.position; - _token(context, 'f'); - _token(context, 'a'); - _token(context, 'l'); - _token(context, 's'); - _token(context, 'e'); - - const end = context.position; - - return { - kind: 'false', - start, - end, - text: context.original.substring(start.offset, end.offset), - value: false, - comments, - }; -} - - -/** - * Read the constant `null` from the context. - * @private - */ -function _readNull(context: JsonParserContext, - comments = _readBlanks(context)): JsonAstConstantNull { - const start = context.position; - - _token(context, 'n'); - _token(context, 'u'); - _token(context, 'l'); - _token(context, 'l'); - - const end = context.position; - - return { - kind: 'null', - start, - end, - text: context.original.substring(start.offset, end.offset), - value: null, - comments: comments, - }; -} - - -/** - * Read the constant `NaN` from the context. - * @private - */ -function _readNaN(context: JsonParserContext, - comments = _readBlanks(context)): JsonAstNumber { - const start = context.position; - - _token(context, 'N'); - _token(context, 'a'); - _token(context, 'N'); - - const end = context.position; - - return { - kind: 'number', - start, - end, - text: context.original.substring(start.offset, end.offset), - value: NaN, - comments: comments, - }; -} - - -/** - * Read an array of JSON values from the context. - * @private - */ -function _readArray(context: JsonParserContext, comments = _readBlanks(context)): JsonAstArray { - const start = context.position; - - // Consume the first delimiter. - _token(context, '['); - const value: JsonArray = []; - const elements: JsonAstNode[] = []; - - _readBlanks(context); - if (_peek(context) != ']') { - const node = _readValue(context); - elements.push(node); - value.push(node.value); - } - - while (_peek(context) != ']') { - _token(context, ','); - - const valueComments = _readBlanks(context); - if ((context.mode & JsonParseMode.TrailingCommasAllowed) !== 0 && _peek(context) === ']') { - break; - } - const node = _readValue(context, valueComments); - elements.push(node); - value.push(node.value); - } - - _token(context, ']'); - - return { - kind: 'array', - start, - end: context.position, - text: context.original.substring(start.offset, context.position.offset), - value, - elements, - comments, - }; -} - - -/** - * Read an identifier from the context. An identifier is a valid JavaScript identifier, and this - * function is only used in Loose mode. - * @private - */ -function _readIdentifier(context: JsonParserContext, - comments = _readBlanks(context)): JsonAstIdentifier { - const start = context.position; - - let char = _peek(context); - if (char && '0123456789'.indexOf(char) != -1) { - const identifierNode = _readNumber(context); - - return { - kind: 'identifier', - start, - end: identifierNode.end, - text: identifierNode.text, - value: identifierNode.value.toString(), - }; - } - - const identValidFirstChar = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMOPQRSTUVWXYZ'; - const identValidChar = '_$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMOPQRSTUVWXYZ0123456789'; - let first = true; - let value = ''; - - while (true) { - char = _token(context); - if (char == undefined - || (first ? identValidFirstChar.indexOf(char) : identValidChar.indexOf(char)) == -1) { - context.position = context.previous; - - return { - kind: 'identifier', - start, - end: context.position, - text: context.original.substr(start.offset, context.position.offset), - value, - comments, - }; - } - - value += char; - first = false; - } -} - - -/** - * Read a property from the context. A property is a string or (in Loose mode only) a number or - * an identifier, followed by a colon `:`. - * @private - */ -function _readProperty(context: JsonParserContext, - comments = _readBlanks(context)): JsonAstKeyValue { - const start = context.position; - - let key; - if ((context.mode & JsonParseMode.IdentifierKeyNamesAllowed) != 0) { - const top = _peek(context); - if (top == '"' || top == '\'') { - key = _readString(context); - } else { - key = _readIdentifier(context); - } - } else { - key = _readString(context); - } - - _readBlanks(context); - _token(context, ':'); - const value = _readValue(context); - const end = context.position; - - return { - kind: 'keyvalue', - key, - value, - start, - end, - text: context.original.substring(start.offset, end.offset), - comments, - }; -} - - -/** - * Read an object of properties -> JSON values from the context. - * @private - */ -function _readObject(context: JsonParserContext, - comments = _readBlanks(context)): JsonAstObject { - const start = context.position; - // Consume the first delimiter. - _token(context, '{'); - const value: JsonObject = {}; - const properties: JsonAstKeyValue[] = []; - - _readBlanks(context); - if (_peek(context) != '}') { - const property = _readProperty(context); - value[property.key.value] = property.value.value; - properties.push(property); - - while (_peek(context) != '}') { - _token(context, ','); - - const propertyComments = _readBlanks(context); - if ((context.mode & JsonParseMode.TrailingCommasAllowed) !== 0 && _peek(context) === '}') { - break; - } - const property = _readProperty(context, propertyComments); - value[property.key.value] = property.value.value; - properties.push(property); - } - } - - _token(context, '}'); - - return { - kind: 'object', - properties, - start, - end: context.position, - value, - text: context.original.substring(start.offset, context.position.offset), - comments, - }; -} - - -/** - * Remove any blank character or comments (in Loose mode) from the context, returning an array - * of comments if any are found. - * @private - */ -function _readBlanks(context: JsonParserContext): (JsonAstComment | JsonAstMultilineComment)[] { - if ((context.mode & JsonParseMode.CommentsAllowed) != 0) { - const comments: (JsonAstComment | JsonAstMultilineComment)[] = []; - while (true) { - const char = context.original[context.position.offset]; - if (char == '/' && context.original[context.position.offset + 1] == '*') { - const start = context.position; - // Multi line comment. - _next(context); - _next(context); - - while (context.original[context.position.offset] != '*' - || context.original[context.position.offset + 1] != '/') { - _next(context); - if (context.position.offset >= context.original.length) { - throw new UnexpectedEndOfInputException(context); - } - } - // Remove "*/". - _next(context); - _next(context); - - comments.push({ - kind: 'multicomment', - start, - end: context.position, - text: context.original.substring(start.offset, context.position.offset), - content: context.original.substring(start.offset + 2, context.position.offset - 2), - }); - } else if (char == '/' && context.original[context.position.offset + 1] == '/') { - const start = context.position; - // Multi line comment. - _next(context); - _next(context); - - while (context.original[context.position.offset] != '\n') { - _next(context); - if (context.position.offset >= context.original.length) { - break; - } - } - - // Remove "\n". - if (context.position.offset < context.original.length) { - _next(context); - } - comments.push({ - kind: 'comment', - start, - end: context.position, - text: context.original.substring(start.offset, context.position.offset), - content: context.original.substring(start.offset + 2, context.position.offset - 1), - }); - } else if (char == ' ' || char == '\t' || char == '\n' || char == '\r' || char == '\f') { - _next(context); - } else { - break; - } - } - - return comments; - } else { - let char = context.original[context.position.offset]; - while (char == ' ' || char == '\t' || char == '\n' || char == '\r' || char == '\f') { - _next(context); - char = context.original[context.position.offset]; - } - - return []; - } -} - - -/** - * Read a JSON value from the context, which can be any form of JSON value. - * @private - */ -function _readValue(context: JsonParserContext, comments = _readBlanks(context)): JsonAstNode { - let result: JsonAstNode; - - // Clean up before. - const char = _peek(context); - switch (char) { - case undefined: - throw new UnexpectedEndOfInputException(context); - - case '-': - case '0': - case '1': - case '2': - case '3': - case '4': - case '5': - case '6': - case '7': - case '8': - case '9': - result = _readNumber(context, comments); - break; - - case '.': - case '+': - if ((context.mode & JsonParseMode.LaxNumberParsingAllowed) == 0) { - throw new InvalidJsonCharacterException(context); - } - result = _readNumber(context, comments); - break; - - case '\'': - case '"': - result = _readString(context, comments); - break; - - case 'I': - if ((context.mode & JsonParseMode.NumberConstantsAllowed) == 0) { - throw new InvalidJsonCharacterException(context); - } - result = _readNumber(context, comments); - break; - - case 'N': - if ((context.mode & JsonParseMode.NumberConstantsAllowed) == 0) { - throw new InvalidJsonCharacterException(context); - } - result = _readNaN(context, comments); - break; - - case 't': - result = _readTrue(context, comments); - break; - case 'f': - result = _readFalse(context, comments); - break; - case 'n': - result = _readNull(context, comments); - break; - - case '[': - result = _readArray(context, comments); - break; - - case '{': - result = _readObject(context, comments); - break; - - default: - throw new InvalidJsonCharacterException(context); - } - - // Clean up after. - _readBlanks(context); - - return result; -} - - -/** - * The Parse mode used for parsing the JSON string. - */ -export enum JsonParseMode { - Strict = 0, // Standard JSON. - CommentsAllowed = 1 << 0, // Allows comments, both single or multi lines. - SingleQuotesAllowed = 1 << 1, // Allow single quoted strings. - IdentifierKeyNamesAllowed = 1 << 2, // Allow identifiers as objectp properties. - TrailingCommasAllowed = 1 << 3, - HexadecimalNumberAllowed = 1 << 4, - MultiLineStringAllowed = 1 << 5, - LaxNumberParsingAllowed = 1 << 6, // Allow `.` or `+` as the first character of a number. - NumberConstantsAllowed = 1 << 7, // Allow -Infinity, Infinity and NaN. - - Default = Strict, - Loose = CommentsAllowed | SingleQuotesAllowed | - IdentifierKeyNamesAllowed | TrailingCommasAllowed | - HexadecimalNumberAllowed | MultiLineStringAllowed | - LaxNumberParsingAllowed | NumberConstantsAllowed, - - Json = Strict, - Json5 = Loose, -} - - -/** - * Parse the JSON string and return its AST. The AST may be losing data (end comments are - * discarded for example, and space characters are not represented in the AST), but all values - * will have a single node in the AST (a 1-to-1 mapping). - * @param input The string to use. - * @param mode The mode to parse the input with. {@see JsonParseMode}. - * @returns {JsonAstNode} The root node of the value of the AST. - */ -export function parseJsonAst(input: string, mode = JsonParseMode.Default): JsonAstNode { - if (mode == JsonParseMode.Default) { - mode = JsonParseMode.Strict; - } - - const context = { - position: { offset: 0, line: 0, character: 0 }, - previous: { offset: 0, line: 0, character: 0 }, - original: input, - comments: undefined, - mode, - }; - - const ast = _readValue(context); - if (context.position.offset < input.length) { - const rest = input.substr(context.position.offset); - const i = rest.length > 20 ? rest.substr(0, 20) + '...' : rest; - throw new Error(`Expected end of file, got "${i}" at ` - + `${context.position.line}:${context.position.character}.`); - } - - return ast; -} - - -/** - * Options for the parseJson() function. - */ -interface ParseJsonOptions { - /** - * If omitted, will only emit errors related to the content of the JSON. If specified, any - * JSON errors will also include the path of the file that caused the error. - */ - path?: string; -} - - -/** - * Parse a JSON string into its value. This discards the AST and only returns the value itself. - * - * If a path option is pass, it also absorbs JSON parsing errors and return a new error with the - * path in it. Useful for showing errors when parsing from a file. - * - * @param input The string to parse. - * @param mode The mode to parse the input with. {@see JsonParseMode}. - * @param options Additional optinos for parsing. - * @returns {JsonValue} The value represented by the JSON string. - */ -export function parseJson( - input: string, - mode = JsonParseMode.Default, - options?: ParseJsonOptions, -): JsonValue { - try { - // Try parsing for the fastest path available, if error, uses our own parser for better errors. - if (mode == JsonParseMode.Strict) { - try { - return JSON.parse(input); - } catch (err) { - return parseJsonAst(input, mode).value; - } - } - - return parseJsonAst(input, mode).value; - } catch (e) { - if (options && options.path && e instanceof JsonException) { - throw new PathSpecificJsonException(options.path, e); - } - throw e; - } -} diff --git a/packages/angular_devkit/core/src/json/parser_benchmark.ts b/packages/angular_devkit/core/src/json/parser_benchmark.ts deleted file mode 100644 index 3c06ec3738ed..000000000000 --- a/packages/angular_devkit/core/src/json/parser_benchmark.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable:no-implicit-dependencies -import { benchmark } from '@_/benchmark'; -import { parseJson, parseJsonAst } from './parser'; - - -const testCase = { - 'hello': [0, 1, 'world', 2], - 'world': { - 'great': 123E-12, - }, -}; -const testCaseJson = JSON.stringify(testCase); - - -describe('parserJson', () => { - benchmark('parseJsonAst', () => parseJsonAst(testCaseJson), () => JSON.parse(testCaseJson)); - benchmark('parseJson', () => parseJson(testCaseJson)); -}); diff --git a/packages/angular_devkit/core/src/json/parser_spec.ts b/packages/angular_devkit/core/src/json/parser_spec.ts deleted file mode 100644 index 56405eb03aed..000000000000 --- a/packages/angular_devkit/core/src/json/parser_spec.ts +++ /dev/null @@ -1,347 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable:no-big-function -import { JsonParseMode, parseJson, parseJsonAst } from './parser'; - - -// Node 6 compatibility. -function entries(x: {[key: string]: any}): any { // tslint:disable-line:no-any - return Object.keys(x).map(k => [k, x[k]]); -} - - -describe('parseJson and parseJsonAst', () => { - describe('generic', () => { - const numbers = { - }; - const errors = [ - '', - '-abcdefghijklmnopqrstuvwxyz', - ]; - - for (const [n, [start, end, text]] of entries(numbers)) { - it(`works for ${JSON.stringify(n)}`, () => { - const ast = parseJsonAst(n); - expect(ast.start).toEqual({offset: start[0], line: start[1], character: start[2]}); - expect(ast.end).toEqual({offset: end[0], line: end[1], character: end[2]}); - expect(ast.value).toEqual(JSON.parse(n)); - expect(parseJson(n)).toEqual(JSON.parse(n)); - expect(ast.text).toBe(text === undefined ? n : text); - }); - } - - for (const n of errors) { - it(`errors for ${JSON.stringify(n)}`, () => { - expect(() => parseJsonAst(n)).toThrow(); - expect(() => parseJson(n)).toThrow(); - expect(() => JSON.parse(n)).toThrow(); - }); - } - }); - - describe('numbers', () => { - const numbers = { - '1234': [[0, 0, 0], [4, 0, 4]], - '12E34': [[0, 0, 0], [5, 0, 5]], - '12E+4': [[0, 0, 0], [5, 0, 5]], - '12E-4': [[0, 0, 0], [5, 0, 5]], - '12E-0004': [[0, 0, 0], [8, 0, 8]], - ' 1234 ': [[3, 0, 3], [7, 0, 7], '1234'], - '\r1234\t': [[1, 0, 1], [5, 0, 5], '1234'], - '\n1234\n': [[1, 1, 0], [5, 1, 4], '1234'], - '0.123': [[0, 0, 0], [5, 0, 5]], - '0': [[0, 0, 0], [1, 0, 1]], - '\n\n\n\n\n0': [[5, 5, 0], [6, 5, 1], '0'], - }; - const errors = [ - '000', - '01', - '1E1+1', - '--', - '0-0', - '-0-0', - '0.0.0', - '0\n.0\n.0', - '0.', - '+1', - 'Infinity', - 'NaN', - '-Infinity', - '+Infinity', - ]; - - for (const [n, [start, end, text]] of entries(numbers)) { - it(`works for ${JSON.stringify(n)}`, () => { - const ast = parseJsonAst(n); - expect(ast.kind).toBe('number'); - expect(ast.start).toEqual({offset: start[0], line: start[1], character: start[2]}); - expect(ast.end).toEqual({offset: end[0], line: end[1], character: end[2]}); - expect(ast.value).toEqual(JSON.parse(n)); - expect(parseJson(n)).toEqual(JSON.parse(n)); - expect(ast.text).toBe(text === undefined ? n : text); - }); - } - - for (const n of errors) { - it(`errors for ${JSON.stringify(n)}`, () => { - expect(() => parseJsonAst(n)).toThrow(); - expect(() => parseJson(n)).toThrow(); - expect(() => JSON.parse(n)).toThrow(); - }); - } - }); - - describe('strings', () => { - const strings = { - '""': [[0, 0, 0], [2, 0, 2]], - '"hello"': [[0, 0, 0], [7, 0, 7]], - '"a\\nb"': [[0, 0, 0], [6, 0, 6]], - '"a\\nb\\tc\\rd\\\\e\\/f\\\"g\\bh\\fi"': [[0, 0, 0], [27, 0, 27]], - '"a\\u1234b"': [[0, 0, 0], [10, 0, 10]], - }; - const errors = [ - '"\\z"', - '\'hello\'', - '"\\', - '"a\\zb"', - '"a', - '"a\nb"', - '"\\\n "', - ]; - - for (const [n, [start, end, text]] of entries(strings)) { - it(`works for ${JSON.stringify(n)}`, () => { - const ast = parseJsonAst(n); - expect(ast.kind).toBe('string'); - expect(ast.start).toEqual({offset: start[0], line: start[1], character: start[2]}); - expect(ast.end).toEqual({offset: end[0], line: end[1], character: end[2]}); - expect(ast.value).toEqual(JSON.parse(n)); - expect(parseJson(n)).toEqual(JSON.parse(n)); - expect(ast.text).toBe(text === undefined ? n : text); - }); - } - - for (const n of errors) { - it(`errors for ${JSON.stringify(n)}`, () => { - expect(() => parseJsonAst(n)).toThrow(); - expect(() => parseJson(n)).toThrow(); - expect(() => JSON.parse(n)).toThrow(); - }); - } - }); - - describe('constants', () => { - const strings = { - 'true': ['true', [0, 0, 0], [4, 0, 4], true], - 'false': ['false', [0, 0, 0], [5, 0, 5], false], - 'null': ['null', [0, 0, 0], [4, 0, 4], null], - }; - const errors = [ - 'undefined', - ]; - - for (const [n, [kind, start, end, value, text]] of entries(strings)) { - it(`works for ${JSON.stringify(n)}`, () => { - const ast = parseJsonAst(n); - expect(ast.kind).toBe(kind); - expect(ast.start).toEqual({offset: start[0], line: start[1], character: start[2]}); - expect(ast.end).toEqual({offset: end[0], line: end[1], character: end[2]}); - expect(ast.value).toEqual(value); - expect(ast.text).toBe(text === undefined ? n : text); - }); - } - - for (const n of errors) { - it(`errors for ${JSON.stringify(n)}`, () => { - expect(() => parseJsonAst(n)).toThrow(); - expect(() => parseJson(n)).toThrow(); - expect(() => JSON.parse(n)).toThrow(); - }); - } - }); - - describe('arrays', () => { - const strings = { - '[0,1,2,3]': [[0, 0, 0], [9, 0, 9]], - '[[0],1,2,3]': [[0, 0, 0], [11, 0, 11]], - '[0\n,\n1,2,3]': [[0, 0, 0], [11, 2, 6]], - '[]': [[0, 0, 0], [2, 0, 2]], - '\n[\n]\n': [[1, 1, 0], [4, 2, 1], '[\n]'], - '[\n]': [[0, 0, 0], [3, 1, 1]], - '[\n\n]': [[0, 0, 0], [4, 2, 1]], - }; - const errors = [ - '[', - '[,]', - '[0,]', - '[,0]', - ]; - - for (const [n, [start, end, text]] of entries(strings)) { - it(`works for ${JSON.stringify(n)}`, () => { - const ast = parseJsonAst(n); - expect(ast.kind).toBe('array'); - expect(ast.start).toEqual({offset: start[0], line: start[1], character: start[2]}); - expect(ast.end).toEqual({offset: end[0], line: end[1], character: end[2]}); - expect(ast.value).toEqual(JSON.parse(n)); - expect(parseJson(n)).toEqual(JSON.parse(n)); - expect(ast.text).toBe(text === undefined ? n : text); - }); - } - - for (const n of errors) { - it(`errors for ${JSON.stringify(n)}`, () => { - expect(() => parseJsonAst(n)).toThrow(); - expect(() => parseJson(n)).toThrow(); - expect(() => JSON.parse(n)).toThrow(); - }); - } - }); - - describe('objects', () => { - const strings = { - '{}': [[0, 0, 0], [2, 0, 2]], - '{\n}': [[0, 0, 0], [3, 1, 1]], - '{"hello": "world"}': [[0, 0, 0], [18, 0, 18]], - '{"hello": 0, "world": 1}': [[0, 0, 0], [24, 0, 24]], - '{"hello": {"hello": {"hello": "world"}}}': [[0, 0, 0], [40, 0, 40]], - }; - const errors = [ - '{', - '{,}', - '{"hello": 0', - ]; - - for (const [n, [start, end, text]] of entries(strings)) { - it(`works for ${JSON.stringify(n)}`, () => { - const ast = parseJsonAst(n); - expect(ast.kind).toBe('object'); - expect(ast.start).toEqual({offset: start[0], line: start[1], character: start[2]}); - expect(ast.end).toEqual({offset: end[0], line: end[1], character: end[2]}); - expect(ast.value).toEqual(JSON.parse(n)); - expect(parseJson(n)).toEqual(JSON.parse(n)); - expect(ast.text).toBe(text === undefined ? n : text); - }); - } - - for (const n of errors) { - it(`errors for ${JSON.stringify(n)}`, () => { - expect(() => parseJsonAst(n)).toThrow(); - expect(() => parseJson(n)).toThrow(); - expect(() => JSON.parse(n)).toThrow(); - }); - } - }); - - describe('loose', () => { - const strings = { - '{\'hello\': 0}': [[0, 0, 0], [12, 0, 12], {'hello': 0}], - '{hello: 0}': [[0, 0, 0], [10, 0, 10], {hello: 0}], - '{1: 0}': [[0, 0, 0], [6, 0, 6], {1: 0}], - '{hello\n:/**/ 0}': [[0, 0, 0], [15, 1, 8], {hello: 0}], - '{\n// hello\n}': [[0, 0, 0], [12, 2, 1], {}], - '{\n/* hello\n*/ }': [[0, 0, 0], [15, 2, 4], {}], - '{}// ': [[0, 0, 0], [2, 0, 2], {}, '{}'], - '{}//': [[0, 0, 0], [2, 0, 2], {}, '{}'], - '{hello:0,}': [[0, 0, 0], [10, 0, 10], {hello: 0}], - '{hello:0/**/,}': [[0, 0, 0], [14, 0, 14], {hello: 0}], - '{hello:0,/**/}': [[0, 0, 0], [14, 0, 14], {hello: 0}], - '{hi:["hello",]}': [[0, 0, 0], [15, 0, 15], {hi: ['hello']}], - '{hi:["hello",/* */]}': [[0, 0, 0], [20, 0, 20], {hi: ['hello']}], - '{hi:["hello"/* */,]}': [[0, 0, 0], [20, 0, 20], {hi: ['hello']}], - '{hi:["hello" , ] , }': [[0, 0, 0], [20, 0, 20], {hi: ['hello']}], - '{hi:"\\\n "}': [[0, 0, 0], [10, 1, 3], {hi: '\n '}], - '{d: -0xdecaf, e: Infinity, f: -Infinity, g: +Infinity, h: NaN,}': [[0, 0, 0], [63, 0, 63], { - d: -0xdecaf, - e: Infinity, - f: -Infinity, - g: Infinity, - h: NaN, - }], - }; - const errors = [ - '{1b: 0}', - ' /*', - '', - '.Infinity', - ]; - - for (const [n, [start, end, value, text]] of entries(strings)) { - it(`works for ${JSON.stringify(n)}`, () => { - const ast = parseJsonAst(n, JsonParseMode.Loose); - expect(ast.kind).toBe('object'); - expect(ast.start).toEqual({offset: start[0], line: start[1], character: start[2]}); - expect(ast.end).toEqual({offset: end[0], line: end[1], character: end[2]}); - expect(ast.value).toEqual(value); - expect(parseJson(n, JsonParseMode.Loose)).toEqual(value); - expect(ast.text).toBe(text === undefined ? n : text); - }); - } - - for (const n of errors) { - it(`errors for ${JSON.stringify(n)}`, () => { - expect(() => parseJsonAst(n, JsonParseMode.Loose)).toThrow(); - expect(() => parseJson(n, JsonParseMode.Loose)).toThrow(); - expect(() => JSON.parse(n)).toThrow(); - }); - } - }); - - describe('complex', () => { - it('strips comments', () => { - expect(parseJson(` - // THIS IS A COMMENT - { - /* THIS IS ALSO A COMMENT */ // IGNORED BECAUSE COMMENT - // AGAIN, COMMENT /* THIS SHOULD NOT BE WEIRD - "a": "this // should not be a comment", - "a2": "this /* should also not be a comment", - /* MULTIPLE - LINE - COMMENT - \o/ */ - "b" /* COMMENT */: /* YOU GUESSED IT */ 1 // COMMENT - , /* STILL VALID */ - "c": 2 - } - `, JsonParseMode.Loose)).toEqual({ - a: 'this // should not be a comment', - a2: 'this /* should also not be a comment', - b: 1, - c: 2, - }); - }); - - it('works with json5.org example', () => { - const input = `{ - // comments - unquoted: 'and you can quote me on that', - 'singleQuotes': 'I can use "double quotes" here', - lineBreaks: "Look, Mom! \\ -No \\\\n's!", - hexadecimal: 0xdecaf, - leadingDecimalPoint: .8675309, andTrailing: 8675309., - positiveSign: +1, - trailingComma: 'in objects', andIn: ['arrays',], - "backwardsCompatible": "with JSON", - }`; - - expect(parseJson(input, JsonParseMode.Json5)).toEqual({ - unquoted: 'and you can quote me on that', - singleQuotes: 'I can use "double quotes" here', - lineBreaks: "Look, Mom! \nNo \\n's!", - hexadecimal: 0xdecaf, - leadingDecimalPoint: .8675309, andTrailing: 8675309., - positiveSign: +1, - trailingComma: 'in objects', - andIn: ['arrays'], - backwardsCompatible: 'with JSON', - }); - }); - }); -}); diff --git a/packages/angular_devkit/core/src/json/schema/index.ts b/packages/angular_devkit/core/src/json/schema/index.ts index e5ff6c4a63c5..706c6d3a281e 100644 --- a/packages/angular_devkit/core/src/json/schema/index.ts +++ b/packages/angular_devkit/core/src/json/schema/index.ts @@ -1,16 +1,18 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 transforms from './transforms'; + export * from './interface'; export * from './pointer'; export * from './registry'; +export * from './schema'; export * from './visitor'; export * from './utility'; -import * as transforms from './transforms'; - export { transforms }; diff --git a/packages/angular_devkit/core/src/json/schema/interface.ts b/packages/angular_devkit/core/src/json/schema/interface.ts index 9861a30889ac..8974746319f7 100644 --- a/packages/angular_devkit/core/src/json/schema/interface.ts +++ b/packages/angular_devkit/core/src/json/schema/interface.ts @@ -1,62 +1,26 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { Observable, SubscribableOrPromise } from 'rxjs'; -import { JsonArray, JsonObject, JsonValue } from '../interface'; + +import { ErrorObject, Format } from 'ajv'; +import { Observable, ObservableInput } from 'rxjs'; +import { JsonArray, JsonObject, JsonValue } from '../utils'; export type JsonPointer = string & { __PRIVATE_DEVKIT_JSON_POINTER: void; }; - -export type SchemaValidatorError = - RefValidatorError | - LimitValidatorError | - AdditionalPropertiesValidatorError | - FormatValidatorError | - RequiredValidatorError; - -export interface SchemaValidatorErrorBase { - keyword: string; - dataPath: string; - message?: string; - data?: JsonValue; -} - -export interface RefValidatorError extends SchemaValidatorErrorBase { - keyword: '$ref'; - params: { ref: string }; -} - -export interface LimitValidatorError extends SchemaValidatorErrorBase { - keyword: 'maxItems' | 'minItems' | 'maxLength' | 'minLength' | 'maxProperties' | 'minProperties'; - params: { limit: number }; -} - -export interface AdditionalPropertiesValidatorError extends SchemaValidatorErrorBase { - keyword: 'additionalProperties'; - params: { additionalProperty: string }; -} - -export interface FormatValidatorError extends SchemaValidatorErrorBase { - keyword: 'format'; - params: { format: string }; -} - -export interface RequiredValidatorError extends SchemaValidatorErrorBase { - keyword: 'required'; - params: { missingProperty: string }; -} - export interface SchemaValidatorResult { data: JsonValue; success: boolean; errors?: SchemaValidatorError[]; } +export type SchemaValidatorError = Partial; + export interface SchemaValidatorOptions { applyPreTransforms?: boolean; applyPostTransforms?: boolean; @@ -64,14 +28,10 @@ export interface SchemaValidatorOptions { } export interface SchemaValidator { - (data: JsonValue, options?: SchemaValidatorOptions): Observable; + (data: JsonValue, options?: SchemaValidatorOptions): Promise; } -export interface SchemaFormatter { - readonly async: boolean; - // tslint:disable-next-line:no-any - validate(data: any): boolean | Observable; -} +export type SchemaFormatter = Format; export interface SchemaFormat { name: string; @@ -84,13 +44,11 @@ export interface SmartDefaultProvider { export interface SchemaKeywordValidator { ( - // tslint:disable-next-line:no-any data: JsonValue, schema: JsonValue, parent: JsonObject | JsonArray | undefined, parentProperty: string | number | undefined, pointer: JsonPointer, - // tslint:disable-next-line:no-any rootData: JsonValue, ): boolean | Observable; } @@ -99,29 +57,35 @@ export interface PromptDefinition { id: string; type: string; message: string; - default?: string | number | boolean | null; - priority: number; - validator?: (value: string) => boolean | string | Promise; + default?: string | string[] | number | boolean | null; + validator?: (value: JsonValue) => boolean | string | Promise; - items?: Array; + items?: Array; raw?: string | JsonObject; + multiselect?: boolean; + propertyTypes: Set; } -export type PromptProvider = (definitions: Array) - => SubscribableOrPromise<{ [id: string]: JsonValue }>; +export type PromptProvider = ( + definitions: Array, +) => ObservableInput<{ [id: string]: JsonValue }>; export interface SchemaRegistry { - compile(schema: Object): Observable; - flatten(schema: JsonObject | string): Observable; + // eslint-disable-next-line @typescript-eslint/no-wrapper-object-types + compile(schema: Object): Promise; + + /** @private */ + ɵflatten(schema: JsonObject | string): Promise; addFormat(format: SchemaFormat): void; addSmartDefaultProvider(source: string, provider: SmartDefaultProvider): void; usePromptProvider(provider: PromptProvider): void; + useXDeprecatedProvider(onUsage: (message: string) => void): void; /** * Add a transformation step before the validation of any Json. * @param {JsonVisitor} visitor The visitor to transform every value. - * @param {JsonVisitor[]} deps A list of other visitors to run before. + * @param {JsonVisitor[]} deps [Deprecated] A list of other visitors to run before. Add transforms in the desired execution order instead. */ addPreTransform(visitor: JsonVisitor, deps?: JsonVisitor[]): void; @@ -130,7 +94,7 @@ export interface SchemaRegistry { * after the POST, so if transformations are not compatible with the Schema it will not result * in an error. * @param {JsonVisitor} visitor The visitor to transform every value. - * @param {JsonVisitor[]} deps A list of other visitors to run before. + * @param {JsonVisitor[]} deps [Deprecated] A list of other visitors to run before. Add transforms in the desired execution order instead. */ addPostTransform(visitor: JsonVisitor, deps?: JsonVisitor[]): void; } diff --git a/packages/angular_devkit/core/src/json/schema/pointer.ts b/packages/angular_devkit/core/src/json/schema/pointer.ts index 39d054169801..ebd51bad80a7 100644 --- a/packages/angular_devkit/core/src/json/schema/pointer.ts +++ b/packages/angular_devkit/core/src/json/schema/pointer.ts @@ -1,20 +1,20 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { JsonPointer } from './interface'; +import { JsonPointer } from './interface'; export function buildJsonPointer(fragments: string[]): JsonPointer { - return ( - '/' + fragments.map(f => { - return f.replace(/~/g, '~0') - .replace(/\//g, '~1'); - }).join('/') - ) as JsonPointer; + return ('/' + + fragments + .map((f) => { + return f.replace(/~/g, '~0').replace(/\//g, '~1'); + }) + .join('/')) as JsonPointer; } export function joinJsonPointer(root: JsonPointer, ...others: string[]): JsonPointer { if (root == '/') { @@ -24,8 +24,15 @@ export function joinJsonPointer(root: JsonPointer, ...others: string[]): JsonPoi return (root + buildJsonPointer(others)) as JsonPointer; } export function parseJsonPointer(pointer: JsonPointer): string[] { - if (pointer === '') { return []; } - if (pointer.charAt(0) !== '/') { throw new Error('Relative pointer: ' + pointer); } + if (pointer === '') { + return []; + } + if (pointer.charAt(0) !== '/') { + throw new Error('Relative pointer: ' + pointer); + } - return pointer.substring(1).split(/\//).map(str => str.replace(/~1/g, '/').replace(/~0/g, '~')); + return pointer + .substring(1) + .split(/\//) + .map((str) => str.replace(/~1/g, '/').replace(/~0/g, '~')); } diff --git a/packages/angular_devkit/core/src/json/schema/prompt_spec.ts b/packages/angular_devkit/core/src/json/schema/prompt_spec.ts index d1bb23c12fdb..355de12b1dcb 100644 --- a/packages/angular_devkit/core/src/json/schema/prompt_spec.ts +++ b/packages/angular_devkit/core/src/json/schema/prompt_spec.ts @@ -1,47 +1,41 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -// tslint:disable:no-any no-big-function -import { map, mergeMap } from 'rxjs/operators'; -import { CoreSchemaRegistry } from './registry'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { CoreSchemaRegistry } from './registry'; describe('Prompt Provider', () => { - it('sets properties with answer', done => { + it('sets properties with answer', async () => { const registry = new CoreSchemaRegistry(); const data: any = {}; - registry.usePromptProvider(async definitions => { + registry.usePromptProvider(async (definitions) => { return { [definitions[0].id]: true }; }); - registry - .compile({ - properties: { - test: { - type: 'boolean', - 'x-prompt': 'test-message', - }, + const validator = await registry.compile({ + properties: { + test: { + type: 'boolean', + 'x-prompt': 'test-message', }, - }) - .pipe( - mergeMap(validator => validator(data)), - map(() => { - expect(data.test).toBe(true); - }), - ) - .toPromise().then(done, done.fail); + }, }); - it('supports mixed schema references', done => { + await validator(data); + expect(data.test).toBe(true); + }); + + it('supports mixed schema references', async () => { const registry = new CoreSchemaRegistry(); const data: any = {}; - registry.usePromptProvider(async definitions => { + registry.usePromptProvider(async (definitions) => { return { '/bool': true, '/test': 'two', @@ -49,127 +43,103 @@ describe('Prompt Provider', () => { }; }); - registry - .compile({ - properties: { - bool: { - $ref: '#/definitions/example', - }, - test: { - type: 'string', - enum: [ - 'one', - 'two', - 'three', - ], - 'x-prompt': { - type: 'list', - 'message': 'other-message', - }, + const validator = await registry.compile({ + properties: { + bool: { + $ref: '#/definitions/example', + }, + test: { + type: 'string', + enum: ['one', 'two', 'three'], + 'x-prompt': { + type: 'list', + 'message': 'other-message', }, - obj: { - properties: { - deep: { - properties: { - three: { - $ref: '#/definitions/test3', - }, + }, + obj: { + properties: { + deep: { + properties: { + three: { + $ref: '#/definitions/test3', }, }, }, }, }, - definitions: { - example: { - type: 'boolean', - 'x-prompt': 'example-message', - }, - test3: { - type: 'string', - 'x-prompt': 'test3-message', - }, + }, + definitions: { + example: { + type: 'boolean', + 'x-prompt': 'example-message', }, - }) - .pipe( - mergeMap(validator => validator(data)), - map(result => { - expect(result.success).toBe(true); - expect(data.bool).toBe(true); - expect(data.test).toBe('two'); - expect(data.obj.deep.three).toEqual('test3-answer'); - }), - ) - .toPromise().then(done, done.fail); + test3: { + type: 'string', + 'x-prompt': 'test3-message', + }, + }, + }); + + const result = await validator(data); + expect(result.success).toBe(true); + expect(data.bool).toBe(true); + expect(data.test).toBe('two'); + expect(data.obj.deep.three).toEqual('test3-answer'); }); describe('with shorthand', () => { - it('supports message value', done => { + it('supports message value', async () => { const registry = new CoreSchemaRegistry(); const data: any = {}; - registry.usePromptProvider(async definitions => { + registry.usePromptProvider(async (definitions) => { expect(definitions.length).toBe(1); expect(definitions[0].message).toBe('test-message'); return {}; }); - registry - .compile({ - properties: { - test: { - type: 'string', - 'x-prompt': 'test-message', - }, + const validator = await registry.compile({ + properties: { + test: { + type: 'string', + 'x-prompt': 'test-message', }, - }) - .pipe( - mergeMap(validator => validator(data)), - ) - .toPromise().then(done, done.fail); + }, + }); + + await validator(data); }); - it('analyzes enums', done => { + it('analyzes enums', async () => { const registry = new CoreSchemaRegistry(); const data: any = {}; - registry.usePromptProvider(async definitions => { + registry.usePromptProvider(async (definitions) => { expect(definitions.length).toBe(1); expect(definitions[0].type).toBe('list'); - expect(definitions[0].items).toEqual([ - 'one', - 'two', - 'three', - ]); + expect(definitions[0].items).toEqual(['one', 'two', 'three']); return {}; }); - registry - .compile({ - properties: { - test: { - type: 'string', - enum: [ - 'one', - 'two', - 'three', - ], - 'x-prompt': 'test-message', - }, + const validator = await registry.compile({ + properties: { + test: { + type: 'string', + enum: ['one', 'two', 'three'], + 'x-prompt': 'test-message', }, - }) - .pipe( - mergeMap(validator => validator(data)), - ) - .toPromise().then(done, done.fail); + }, + }); + await validator(data); }); - it('analyzes boolean properties', done => { + it('analyzes boolean properties', async () => { const registry = new CoreSchemaRegistry(); const data: any = {}; - registry.usePromptProvider(async definitions => { + registry.usePromptProvider(async (definitions) => { expect(definitions.length).toBe(1); expect(definitions[0].type).toBe('confirmation'); expect(definitions[0].items).toBeUndefined(); @@ -177,134 +147,232 @@ describe('Prompt Provider', () => { return {}; }); - registry - .compile({ - properties: { - test: { - type: 'boolean', - 'x-prompt': 'test-message', - }, + const validator = await registry.compile({ + properties: { + test: { + type: 'boolean', + 'x-prompt': 'test-message', }, - }) - .pipe( - mergeMap(validator => validator(data)), - ) - .toPromise().then(done, done.fail); + }, + }); + await validator(data); }); - }); describe('with longhand', () => { - it('supports message option', done => { + it('supports message option', async () => { const registry = new CoreSchemaRegistry(); const data: any = {}; - registry.usePromptProvider(async definitions => { + registry.usePromptProvider(async (definitions) => { expect(definitions.length).toBe(1); expect(definitions[0].message).toBe('test-message'); return {}; }); - registry - .compile({ - properties: { - test: { - type: 'string', - 'x-prompt': { - 'message': 'test-message', - }, + const validator = await registry.compile({ + properties: { + test: { + type: 'string', + 'x-prompt': { + 'message': 'test-message', }, }, - }) - .pipe( - mergeMap(validator => validator(data)), - ) - .toPromise().then(done, done.fail); + }, + }); + await validator(data); }); - it('analyzes enums WITH explicit list type', done => { + it('analyzes enums WITH explicit list type', async () => { const registry = new CoreSchemaRegistry(); const data: any = {}; - registry.usePromptProvider(async definitions => { + registry.usePromptProvider(async (definitions) => { expect(definitions.length).toBe(1); expect(definitions[0].type).toBe('list'); + expect(definitions[0].items).toEqual(['one', 'two', 'three']); + + return { [definitions[0].id]: 'one' }; + }); + + const validator = await registry.compile({ + properties: { + test: { + type: 'string', + enum: ['one', 'two', 'three'], + 'x-prompt': { + 'type': 'list', + 'message': 'test-message', + }, + }, + }, + }); + await validator(data); + }); + + it('analyzes list with true multiselect option and object items', async () => { + const registry = new CoreSchemaRegistry(); + const data: any = {}; + + registry.usePromptProvider(async (definitions) => { + expect(definitions.length).toBe(1); + expect(definitions[0].type).toBe('list'); + expect(definitions[0].multiselect).toBe(true); expect(definitions[0].items).toEqual([ - 'one', - 'two', - 'three', + { 'value': 'one', 'label': 'one' }, + { 'value': 'two', 'label': 'two' }, ]); - return { [definitions[0].id]: 'one' }; + return { [definitions[0].id]: { 'value': 'one', 'label': 'one' } }; }); - registry - .compile({ - properties: { - test: { - type: 'string', - enum: [ - 'one', - 'two', - 'three', + const validator = await registry.compile({ + properties: { + test: { + type: 'array', + 'x-prompt': { + 'type': 'list', + 'multiselect': true, + 'items': [ + { 'value': 'one', 'label': 'one' }, + { 'value': 'two', 'label': 'two' }, ], - 'x-prompt': { - 'type': 'list', - 'message': 'test-message', - }, + 'message': 'test-message', }, }, - }) - .pipe( - mergeMap(validator => validator(data)), - ) - .toPromise().then(done, done.fail); + }, + }); + await validator(data); }); - it('analyzes enums WITHOUT explicit list type', done => { + it('analyzes list with false multiselect option and object items', async () => { const registry = new CoreSchemaRegistry(); const data: any = {}; - registry.usePromptProvider(async definitions => { + registry.usePromptProvider(async (definitions) => { expect(definitions.length).toBe(1); expect(definitions[0].type).toBe('list'); + expect(definitions[0].multiselect).toBe(false); expect(definitions[0].items).toEqual([ - 'one', - 'two', - 'three', + { 'value': 'one', 'label': 'one' }, + { 'value': 'two', 'label': 'two' }, ]); - return {}; + return { [definitions[0].id]: { 'value': 'one', 'label': 'one' } }; }); - registry - .compile({ - properties: { - test: { - type: 'string', - enum: [ - 'one', - 'two', - 'three', + const validator = await registry.compile({ + properties: { + test: { + type: 'array', + 'x-prompt': { + 'type': 'list', + 'multiselect': false, + 'items': [ + { 'value': 'one', 'label': 'one' }, + { 'value': 'two', 'label': 'two' }, ], - 'x-prompt': { - 'message': 'test-message', - }, + 'message': 'test-message', }, }, - }) - .pipe( - mergeMap(validator => validator(data)), - ) - .toPromise().then(done, done.fail); + }, + }); + await validator(data); }); - it('analyzes boolean properties', done => { + it('analyzes list without multiselect option and object items', async () => { const registry = new CoreSchemaRegistry(); const data: any = {}; - registry.usePromptProvider(async definitions => { + registry.usePromptProvider(async (definitions) => { + expect(definitions.length).toBe(1); + expect(definitions[0].type).toBe('list'); + expect(definitions[0].multiselect).toBe(true); + expect(definitions[0].items).toEqual([ + { 'value': 'one', 'label': 'one' }, + { 'value': 'two', 'label': 'two' }, + ]); + + return { [definitions[0].id]: { 'value': 'two', 'label': 'two' } }; + }); + + const validator = await registry.compile({ + properties: { + test: { + type: 'array', + 'x-prompt': { + 'type': 'list', + 'items': [ + { 'value': 'one', 'label': 'one' }, + { 'value': 'two', 'label': 'two' }, + ], + 'message': 'test-message', + }, + }, + }, + }); + await validator(data); + }); + + it('analyzes enums WITHOUT explicit list type', async () => { + const registry = new CoreSchemaRegistry(); + const data: any = {}; + + registry.usePromptProvider(async (definitions) => { + expect(definitions.length).toBe(1); + expect(definitions[0].type).toBe('list'); + expect(definitions[0].multiselect).toBeFalsy(); + expect(definitions[0].items).toEqual(['one', 'two', 'three']); + + return {}; + }); + const validator = await registry.compile({ + properties: { + test: { + type: 'string', + enum: ['one', 'two', 'three'], + 'x-prompt': { + 'message': 'test-message', + }, + }, + }, + }); + await validator(data); + }); + + it('analyzes enums WITHOUT explicit list type and multiselect', async () => { + const registry = new CoreSchemaRegistry(); + const data: any = {}; + + registry.usePromptProvider(async (definitions) => { + expect(definitions.length).toBe(1); + expect(definitions[0].type).toBe('list'); + expect(definitions[0].multiselect).toBe(true); + expect(definitions[0].items).toEqual(['one', 'two', 'three']); + + return {}; + }); + + const validator = await registry.compile({ + properties: { + test: { + type: 'array', + items: { + enum: ['one', 'two', 'three'], + }, + 'x-prompt': 'test-message', + }, + }, + }); + await validator(data); + }); + + it('analyzes boolean properties', async () => { + const registry = new CoreSchemaRegistry(); + const data: any = {}; + + registry.usePromptProvider(async (definitions) => { expect(definitions.length).toBe(1); expect(definitions[0].type).toBe('confirmation'); expect(definitions[0].items).toBeUndefined(); @@ -312,28 +380,24 @@ describe('Prompt Provider', () => { return {}; }); - registry - .compile({ - properties: { - test: { - type: 'boolean', - 'x-prompt': { - 'message': 'test-message', - }, + const validator = await registry.compile({ + properties: { + test: { + type: 'boolean', + 'x-prompt': { + 'message': 'test-message', }, }, - }) - .pipe( - mergeMap(validator => validator(data)), - ) - .toPromise().then(done, done.fail); + }, + }); + await validator(data); }); - it('allows prompt type override', done => { + it('allows prompt type override', async () => { const registry = new CoreSchemaRegistry(); const data: any = {}; - registry.usePromptProvider(async definitions => { + registry.usePromptProvider(async (definitions) => { expect(definitions.length).toBe(1); expect(definitions[0].type).toBe('input'); expect(definitions[0].items).toBeUndefined(); @@ -341,24 +405,18 @@ describe('Prompt Provider', () => { return {}; }); - registry - .compile({ - properties: { - test: { - type: 'boolean', - 'x-prompt': { - 'type': 'input', - 'message': 'test-message', - }, + const validator = await registry.compile({ + properties: { + test: { + type: 'boolean', + 'x-prompt': { + 'type': 'input', + 'message': 'test-message', }, }, - }) - .pipe( - mergeMap(validator => validator(data)), - ) - .toPromise().then(done, done.fail); + }, + }); + await validator(data); }); - }); - }); diff --git a/packages/angular_devkit/core/src/json/schema/registry.ts b/packages/angular_devkit/core/src/json/schema/registry.ts index e55a111ad966..bd862a0e5f17 100644 --- a/packages/angular_devkit/core/src/json/schema/registry.ts +++ b/packages/angular_devkit/core/src/json/schema/registry.ts @@ -1,25 +1,26 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 ajv from 'ajv'; -import * as http from 'http'; -import { Observable, from, of, throwError } from 'rxjs'; -import { concatMap, map, switchMap, tap } from 'rxjs/operators'; -import * as Url from 'url'; -import { BaseException } from '../../exception/exception'; -import { PartiallyOrderedSet, deepCopy, isObservable } from '../../utils'; -import { JsonArray, JsonObject, JsonValue, isJsonObject } from '../interface'; + +import Ajv, { SchemaObjCxt, ValidateFunction } from 'ajv'; +import ajvAddFormats from 'ajv-formats'; +import * as http from 'node:http'; +import * as https from 'node:https'; +import * as Url from 'node:url'; +import { Observable, from, isObservable, lastValueFrom } from 'rxjs'; +import { BaseException } from '../../exception'; +import { PartiallyOrderedSet } from '../../utils'; +import { JsonArray, JsonObject, JsonValue, isJsonObject } from '../utils'; import { JsonPointer, JsonVisitor, PromptDefinition, PromptProvider, SchemaFormat, - SchemaFormatter, SchemaRegistry, SchemaValidator, SchemaValidatorError, @@ -27,23 +28,13 @@ import { SchemaValidatorResult, SmartDefaultProvider, } from './interface'; +import { JsonSchema } from './schema'; +import { getTypesOfSchema } from './utility'; import { visitJson, visitJsonSchema } from './visitor'; -// This interface should be exported from ajv, but they only export the class and not the type. -interface AjvValidationError { - message: string; - errors: Array; - ajv: true; - validation: true; -} - -interface AjvRefMap { - refs: string[]; - refVal: any; // tslint:disable-line:no-any - schema: JsonObject; -} - -export type UriHandler = (uri: string) => Observable | null | undefined; +export type UriHandler = ( + uri: string, +) => Observable | Promise | null | undefined; export class SchemaValidationException extends BaseException { public readonly errors: SchemaValidatorError[]; @@ -54,6 +45,7 @@ export class SchemaValidationException extends BaseException { ) { if (!errors || errors.length === 0) { super('Schema validation failed.'); + this.errors = []; return; } @@ -69,9 +61,24 @@ export class SchemaValidationException extends BaseException { } const messages = errors.map((err) => { - let message = `Data path ${JSON.stringify(err.dataPath)} ${err.message}`; if (err.keyword === 'additionalProperties') { - message += `(${err.params.additionalProperty})`; + const unknown = err.params?.additionalProperty; + // `parentSchema` is the schema that rejected the property, which ajv only attaches when + // the validator was created with `verbose: true`. A schema that declares no `properties` + // of its own, such as one using only `patternProperties`, has no options to offer. + const known = Object.keys(err.parentSchema?.properties ?? {}); + + return ( + `Unknown option "${unknown}"${err.instancePath ? ` at "${err.instancePath}"` : ''}.` + + (known.length ? ` Valid options are: ${known.join(', ')}.` : '') + ); + } + + let message = `Data path ${JSON.stringify(err.instancePath)} ${err.message}`; + if (err.keyword === 'enum' && err.params) { + message += `. Allowed values are: ${(err.params.allowedValues as string[] | undefined) + ?.map((v) => `"${v}"`) + .join(', ')}`; } return message + '.'; @@ -87,7 +94,7 @@ interface SchemaInfo { } export class CoreSchemaRegistry implements SchemaRegistry { - private _ajv: ajv.Ajv; + private _ajv: Ajv; private _uriCache = new Map(); private _uriHandlers = new Set(); private _pre = new PartiallyOrderedSet(); @@ -100,48 +107,50 @@ export class CoreSchemaRegistry implements SchemaRegistry { private _sourceMap = new Map>(); constructor(formats: SchemaFormat[] = []) { - /** - * Build an AJV instance that will be used to validate schemas. - */ - - const formatsObj: { [name: string]: SchemaFormatter } = {}; - - for (const format of formats) { - formatsObj[format.name] = format.formatter; - } - - this._ajv = ajv({ - formats: formatsObj, + this._ajv = new Ajv({ + strict: false, loadSchema: (uri: string) => this._fetch(uri), - schemaId: 'auto', passContext: true, + // Needed to list the valid options of the object an unknown option was found in. + verbose: true, }); - this._ajv.addMetaSchema(require('ajv/lib/refs/json-schema-draft-04.json')); - this._ajv.addMetaSchema(require('ajv/lib/refs/json-schema-draft-06.json')); + ajvAddFormats(this._ajv); + + for (const format of formats) { + this.addFormat(format); + } } - private _fetch(uri: string): Promise { + private async _fetch(uri: string): Promise { const maybeSchema = this._uriCache.get(uri); if (maybeSchema) { - return Promise.resolve(maybeSchema); + return maybeSchema; } // Try all handlers, one after the other. - for (const maybeHandler of this._uriHandlers) { - const handler = maybeHandler(uri); - if (handler) { - // The AJV API only understands Promises. - return handler.pipe( - tap(json => this._uriCache.set(uri, json)), - ).toPromise(); + for (const handler of this._uriHandlers) { + let handlerResult = handler(uri); + if (handlerResult === null || handlerResult === undefined) { + continue; } + + if (isObservable(handlerResult)) { + handlerResult = lastValueFrom(handlerResult); + } + + const value = await handlerResult; + this._uriCache.set(uri, value); + + return value; } // If none are found, handle using http client. return new Promise((resolve, reject) => { - http.get(uri, res => { + const url = new Url.URL(uri); + const client = url.protocol === 'https:' ? https : http; + client.get(url, (res) => { if (!res.statusCode || res.statusCode >= 300) { // Consume the rest of the data to free memory. res.resume(); @@ -149,12 +158,12 @@ export class CoreSchemaRegistry implements SchemaRegistry { } else { res.setEncoding('utf8'); let data = ''; - res.on('data', chunk => { + res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { - const json = JSON.parse(data); + const json = JSON.parse(data) as JsonObject; this._uriCache.set(uri, json); resolve(json); } catch (err) { @@ -169,9 +178,9 @@ export class CoreSchemaRegistry implements SchemaRegistry { /** * Add a transformation step before the validation of any Json. * @param {JsonVisitor} visitor The visitor to transform every value. - * @param {JsonVisitor[]} deps A list of other visitors to run before. + * @param {JsonVisitor[]} deps [Deprecated] A list of other visitors to run before. Add transforms in the desired execution order instead. */ - addPreTransform(visitor: JsonVisitor, deps?: JsonVisitor[]) { + addPreTransform(visitor: JsonVisitor, deps?: JsonVisitor[]): void { this._pre.add(visitor, deps); } @@ -180,115 +189,85 @@ export class CoreSchemaRegistry implements SchemaRegistry { * after the POST, so if transformations are not compatible with the Schema it will not result * in an error. * @param {JsonVisitor} visitor The visitor to transform every value. - * @param {JsonVisitor[]} deps A list of other visitors to run before. + * @param {JsonVisitor[]} deps [Deprecated] A list of other visitors to run before. Add transforms in the desired execution order instead. */ - addPostTransform(visitor: JsonVisitor, deps?: JsonVisitor[]) { + addPostTransform(visitor: JsonVisitor, deps?: JsonVisitor[]): void { this._post.add(visitor, deps); } protected _resolver( ref: string, - validate: ajv.ValidateFunction, - ): { context?: ajv.ValidateFunction, schema?: JsonObject } { - if (!validate || !validate.refs || !validate.refVal || !ref) { + validate?: ValidateFunction, + ): { context?: ValidateFunction; schema?: JsonObject } { + if (!validate || !ref) { return {}; } - let refMap = validate as AjvRefMap; - const rootRefMap = validate.root as AjvRefMap; + const schema = validate.schemaEnv.root.schema; + const id = typeof schema === 'object' ? schema.$id : null; - // Resolve from the root if it's different. - if (validate.root && validate.schema !== rootRefMap.schema) { - refMap = rootRefMap; - } - - const schema = refMap.schema ? typeof refMap.schema == 'object' && refMap.schema : null; - const maybeId = schema ? (schema as JsonObject).id || (schema as JsonObject).$id : null; - - if (typeof maybeId == 'string') { - ref = Url.resolve(maybeId, ref); - } + let fullReference = ref; + if (typeof id === 'string') { + fullReference = Url.resolve(id, ref); - let fullReference = (ref[0] === '#' && maybeId) ? maybeId + ref : ref; - if (fullReference.endsWith('#')) { - fullReference = fullReference.slice(0, -1); + if (ref.startsWith('#')) { + fullReference = id + fullReference; + } } - // tslint:disable-next-line:no-any - const context = validate.refVal[(validate.refs as any)[fullReference]]; + const resolvedSchema = this._ajv.getSchema(fullReference); - if (typeof context == 'function') { - // Context will be a function if the schema isn't loaded yet, and an actual schema if it's - // synchronously available. - return { context, schema: context && context.schema as JsonObject }; - } else { - return { context: validate, schema: context as JsonObject }; - } + return { + context: resolvedSchema?.schemaEnv.validate, + schema: resolvedSchema?.schema as JsonObject, + }; } /** * Flatten the Schema, resolving and replacing all the refs. Makes it into a synchronous schema * that is also easier to traverse. Does not cache the result. * + * Producing a flatten schema document does not in all cases produce a schema with identical behavior to the original. + * See: https://json-schema.org/draft/2019-09/json-schema-core.html#rfc.appendix.B.2 + * * @param schema The schema or URI to flatten. - * @returns An Observable of the flattened schema object. + * @return A Promise that resolves to the flattened schema object. + * @private since 11.2 without replacement. */ - flatten(schema: JsonObject): Observable { + async ɵflatten(schema: JsonObject): Promise { this._ajv.removeSchema(schema); - - // Supports both synchronous and asynchronous compilation, by trying the synchronous - // version first, then if refs are missing this will fails. - // We also add any refs from external fetched schemas so that those will also be used - // in synchronous (if available). - let validator: Observable; - try { - this._currentCompilationSchemaInfo = undefined; - validator = of(this._ajv.compile(schema)).pipe( - tap(() => this._currentCompilationSchemaInfo = undefined), - ); - } catch (e) { - // Propagate the error. - if (!(e instanceof (ajv.MissingRefError as {} as Function))) { - return throwError(e); + this._currentCompilationSchemaInfo = undefined; + const validate = await this._ajv.compileAsync(schema); + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + + function visitor( + current: JsonObject | JsonArray, + pointer: JsonPointer, + parentSchema?: JsonObject | JsonArray, + index?: string, + ) { + if ( + current && + parentSchema && + index && + isJsonObject(current) && + Object.prototype.hasOwnProperty.call(current, '$ref') && + typeof current['$ref'] == 'string' + ) { + const resolved = self._resolver(current['$ref'], validate); + + if (resolved.schema) { + (parentSchema as JsonObject)[index] = resolved.schema; + } } - - this._currentCompilationSchemaInfo = undefined; - validator = from(this._ajv.compileAsync(schema)).pipe( - tap(() => this._currentCompilationSchemaInfo = undefined), - ); } - return validator.pipe( - switchMap(validate => { - const self = this; - - function visitor( - current: JsonObject | JsonArray, - pointer: JsonPointer, - parentSchema?: JsonObject | JsonArray, - index?: string, - ) { - if (current - && parentSchema - && index - && isJsonObject(current) - && current.hasOwnProperty('$ref') - && typeof current['$ref'] == 'string' - ) { - const resolved = self._resolver(current['$ref'] as string, validate); + const schemaCopy = structuredClone(validate.schema as JsonObject); + visitJsonSchema(schemaCopy, visitor); - if (resolved.schema) { - (parentSchema as JsonObject)[index] = resolved.schema; - } - } - } - - const schema = deepCopy(validate.schema as JsonObject); - visitJsonSchema(schema, visitor); - - return of(schema); - }), - ); + return schemaCopy; } /** @@ -296,178 +275,136 @@ export class CoreSchemaRegistry implements SchemaRegistry { * * @param schema The schema to validate. If a string, will fetch the schema before compiling it * (using schema as a URI). - * @returns An Observable of the Validation function. */ - compile(schema: JsonObject): Observable { + async compile(schema: JsonSchema): Promise { + const validate = await this._compile(schema); + + return (value, options) => validate(value, options); + } + + private async _compile( + schema: JsonSchema, + ): Promise< + (data: JsonValue, options?: SchemaValidatorOptions) => Promise + > { + if (typeof schema === 'boolean') { + return async (data) => ({ success: schema, data }); + } + const schemaInfo: SchemaInfo = { smartDefaultRecord: new Map(), promptDefinitions: [], }; this._ajv.removeSchema(schema); + let validator: ValidateFunction; - // Supports both synchronous and asynchronous compilation, by trying the synchronous - // version first, then if refs are missing this will fails. - // We also add any refs from external fetched schemas so that those will also be used - // in synchronous (if available). - let validator: Observable; try { this._currentCompilationSchemaInfo = schemaInfo; - validator = of(this._ajv.compile(schema)); + validator = this._ajv.compile(schema); } catch (e) { - // Propagate the error. - if (!(e instanceof (ajv.MissingRefError as {} as Function))) { - return throwError(e); + // This should eventually be refactored so that we we handle race condition where the same schema is validated at the same time. + if (!(e instanceof Ajv.MissingRefError)) { + throw e; } - try { - validator = from(this._ajv.compileAsync(schema)); - } catch (e) { - return throwError(e); - } + validator = await this._ajv.compileAsync(schema); + } finally { + this._currentCompilationSchemaInfo = undefined; } - return validator - .pipe( - map(validate => (data: JsonValue, options?: SchemaValidatorOptions) => { - const validationOptions: SchemaValidatorOptions = { - withPrompts: true, - applyPostTransforms: true, - applyPreTransforms: true, - ...options, - }; - const validationContext = { - promptFieldsWithValue: new Set(), - }; - - let result = of(data); - if (validationOptions.applyPreTransforms) { - // tslint:disable-next-line:no-any https://github.com/ReactiveX/rxjs/issues/3989 - result = (result as any).pipe( - ...[...this._pre].map(visitor => concatMap((data: JsonValue) => { - return visitJson(data, visitor, schema, this._resolver, validate); - })), - ); + return async (data: JsonValue, options?: SchemaValidatorOptions) => { + const validationOptions: SchemaValidatorOptions = { + withPrompts: true, + applyPostTransforms: true, + applyPreTransforms: true, + ...options, + }; + const validationContext = { + promptFieldsWithValue: new Set(), + }; + + // Apply pre-validation transforms + if (validationOptions.applyPreTransforms) { + for (const visitor of this._pre.values()) { + data = await lastValueFrom( + visitJson(data, visitor, schema, this._resolver.bind(this), validator), + ); + } + } + + // Apply smart defaults + await this._applySmartDefaults(data, schemaInfo.smartDefaultRecord); + + // Apply prompts + if (validationOptions.withPrompts) { + const visitor: JsonVisitor = (value, pointer) => { + if (value !== undefined) { + validationContext.promptFieldsWithValue.add(pointer); } - return result.pipe( - switchMap(updateData => this._applySmartDefaults( - updateData, - schemaInfo.smartDefaultRecord, - )), - switchMap(updatedData => { - if (validationOptions.withPrompts === false) { - return of(updatedData); - } + return value; + }; + if (typeof schema === 'object') { + await lastValueFrom( + visitJson(data, visitor, schema, this._resolver.bind(this), validator), + ); + } - const visitor: JsonVisitor = (value, pointer) => { - if (value !== undefined) { - validationContext.promptFieldsWithValue.add(pointer); - } + const definitions = schemaInfo.promptDefinitions.filter( + (def) => !validationContext.promptFieldsWithValue.has(def.id), + ); - return value; - }; + if (definitions.length > 0) { + await this._applyPrompts(data, definitions); + } + } - return visitJson(updatedData, visitor, schema, this._resolver, validate); - }), - switchMap(updatedData => { - if (validationOptions.withPrompts === false) { - return of(updatedData); - } + // Validate using ajv + try { + // eslint-disable-next-line @typescript-eslint/await-thenable + const success = await validator.call(validationContext, data); - const definitions = schemaInfo.promptDefinitions - .filter(def => !validationContext.promptFieldsWithValue.has(def.id)); + if (!success) { + return { data, success, errors: validator.errors ?? [] }; + } + } catch (error) { + if (error instanceof Ajv.ValidationError) { + return { data, success: false, errors: error.errors }; + } - if (this._promptProvider && definitions.length > 0) { - return from(this._applyPrompts(updatedData, definitions)); - } else { - return of(updatedData); - } - }), - switchMap(updatedData => { - const result = validate.call(validationContext, updatedData); - - return typeof result == 'boolean' - ? of([updatedData, result]) - : from((result as Promise) - .then(r => [updatedData, true]) - .catch((err: Error | AjvValidationError) => { - if ((err as AjvValidationError).ajv) { - validate.errors = (err as AjvValidationError).errors; - - return Promise.resolve([updatedData, false]); - } - - return Promise.reject(err); - })); - }), - switchMap(([data, valid]: [JsonValue, boolean]) => { - if (valid) { - let result = of(data); - - if (validationOptions.applyPostTransforms) { - // tslint:disable-next-line:no-any https://github.com/ReactiveX/rxjs/issues/3989 - result = (result as any).pipe( - ...[...this._post].map(visitor => concatMap((data: JsonValue) => { - return visitJson(data, visitor, schema, this._resolver, validate); - })), - ); - } - - return result.pipe( - map(data => [data, valid]), - ); - } else { - return of([data, valid]); - } - }), - map(([data, valid]: [JsonValue, boolean]) => { - if (valid) { - return { data, success: true } as SchemaValidatorResult; - } + throw error; + } - return { - data, - success: false, - errors: (validate.errors || []), - } as SchemaValidatorResult; - }), + // Apply post-validation transforms + if (validationOptions.applyPostTransforms) { + for (const visitor of this._post.values()) { + data = await lastValueFrom( + visitJson(data, visitor, schema, this._resolver.bind(this), validator), ); - }), - ); - } - - addFormat(format: SchemaFormat): void { - // tslint:disable-next-line:no-any - const validate = (data: any) => { - const result = format.formatter.validate(data); - - if (typeof result == 'boolean') { - return result; - } else { - return result.toPromise(); + } } + + return { data, success: true }; }; + } - this._ajv.addFormat(format.name, { - async: format.formatter.async, - validate, - // AJV typings list `compare` as required, but it is optional. - // tslint:disable-next-line:no-any - } as any); + addFormat(format: SchemaFormat): void { + this._ajv.addFormat(format.name, format.formatter); } - addSmartDefaultProvider(source: string, provider: SmartDefaultProvider) { + addSmartDefaultProvider(source: string, provider: SmartDefaultProvider): void { if (this._sourceMap.has(source)) { throw new Error(source); } - this._sourceMap.set(source, provider); + this._sourceMap.set(source, provider as unknown as SmartDefaultProvider<{}>); if (!this._smartDefaultKeyword) { this._smartDefaultKeyword = true; - this._ajv.addKeyword('$default', { + this._ajv.addKeyword({ + keyword: '$default', errors: false, valid: true, compile: (schema, _parentSchema, it) => { @@ -477,11 +414,8 @@ export class CoreSchemaRegistry implements SchemaRegistry { } // We cheat, heavily. - compilationSchemInfo.smartDefaultRecord.set( - // tslint:disable-next-line:no-any - JSON.stringify((it as any).dataPathArr.slice(1, (it as any).dataLevel + 1) as string[]), - schema, - ); + const pathArray = this.normalizeDataPathArr(it); + compilationSchemInfo.smartDefaultRecord.set(JSON.stringify(pathArray), schema); return () => true; }, @@ -491,17 +425,17 @@ export class CoreSchemaRegistry implements SchemaRegistry { '$source': { type: 'string' }, }, additionalProperties: true, - required: [ '$source' ], + required: ['$source'], }, }); } } - registerUriHandler(handler: UriHandler) { + registerUriHandler(handler: UriHandler): void { this._uriHandlers.add(handler); } - usePromptProvider(provider: PromptProvider) { + usePromptProvider(provider: PromptProvider): void { const isSetup = !!this._promptProvider; this._promptProvider = provider; @@ -510,21 +444,20 @@ export class CoreSchemaRegistry implements SchemaRegistry { return; } - this._ajv.addKeyword('x-prompt', { + this._ajv.addKeyword({ + keyword: 'x-prompt', errors: false, valid: true, - compile: (schema, parentSchema: JsonObject, it) => { + compile: (schema, parentSchema, it) => { const compilationSchemInfo = this._currentCompilationSchemaInfo; if (!compilationSchemInfo) { return () => true; } - // tslint:disable-next-line:no-any - const pathArray = ((it as any).dataPathArr as string[]).slice(1, it.dataLevel + 1); - const path = '/' + pathArray.map(p => p.replace(/^\'/, '').replace(/\'$/, '')).join('/'); + const path = '/' + this.normalizeDataPathArr(it).join('/'); let type: string | undefined; - let items: Array | undefined; + let items: Array | undefined; let message: string; if (typeof schema == 'string') { message = schema; @@ -534,21 +467,38 @@ export class CoreSchemaRegistry implements SchemaRegistry { items = schema.items; } + const propertyTypes = getTypesOfSchema(parentSchema as JsonObject); if (!type) { - if (parentSchema.type === 'boolean') { + if (propertyTypes.size === 1 && propertyTypes.has('boolean')) { type = 'confirmation'; - } else if (Array.isArray(parentSchema.enum)) { + } else if (Array.isArray((parentSchema as JsonObject).enum)) { + type = 'list'; + } else if ( + propertyTypes.size === 1 && + propertyTypes.has('array') && + (parentSchema as JsonObject).items && + Array.isArray(((parentSchema as JsonObject).items as JsonObject).enum) + ) { type = 'list'; } else { type = 'input'; } } - if (type === 'list' && !items) { - if (Array.isArray(parentSchema.enum)) { - type = 'list'; + let multiselect; + if (type === 'list') { + multiselect = + schema.multiselect === undefined + ? propertyTypes.size === 1 && propertyTypes.has('array') + : schema.multiselect; + + const enumValues = multiselect + ? (parentSchema as JsonObject).items && + ((parentSchema as JsonObject).items as JsonObject).enum + : (parentSchema as JsonObject).enum; + if (!items && Array.isArray(enumValues)) { items = []; - for (const value of parentSchema.enum) { + for (const value of enumValues) { if (typeof value == 'string') { items.push(value); } else if (typeof value == 'object') { @@ -564,30 +514,44 @@ export class CoreSchemaRegistry implements SchemaRegistry { id: path, type, message, - priority: 0, raw: schema, items, - default: typeof parentSchema.default == 'object' ? undefined : parentSchema.default, - async validator(data: string) { - const result = it.self.validate(parentSchema, data); - if (typeof result === 'boolean') { - return result; - } else { - try { - await result; - - return true; - } catch { - return false; + multiselect, + propertyTypes, + default: + typeof (parentSchema as JsonObject).default == 'object' && + (parentSchema as JsonObject).default !== null && + !Array.isArray((parentSchema as JsonObject).default) + ? undefined + : ((parentSchema as JsonObject).default as string[]), + async validator(data: JsonValue): Promise { + try { + const result = await it.self.validate(parentSchema, data); + // If the schema is sync then false will be returned on validation failure + if (result) { + return result as boolean | string; + } else if (it.self.errors?.length) { + // Validation errors will be present on the Ajv instance when sync + return it.self.errors[0].message as string; + } + } catch (e) { + const validationError = e as { errors?: Error[] }; + // If the schema is async then an error will be thrown on validation failure + if (Array.isArray(validationError.errors) && validationError.errors.length) { + return validationError.errors[0].message; } } + + return false; }, }; compilationSchemInfo.promptDefinitions.push(definition); - return function(this: { promptFieldsWithValue: Set }) { - if (this) { + return function (this: { promptFieldsWithValue: Set }) { + // If 'this' is undefined in the call, then it defaults to the global + // 'this'. + if (this && this.promptFieldsWithValue) { this.promptFieldsWithValue.add(path); } @@ -604,132 +568,112 @@ export class CoreSchemaRegistry implements SchemaRegistry { 'message': { type: 'string' }, }, additionalProperties: true, - required: [ 'message' ], + required: ['message'], }, ], }, }); } - private _applyPrompts(data: T, prompts: Array): Observable { + private async _applyPrompts(data: JsonValue, prompts: Array): Promise { const provider = this._promptProvider; if (!provider) { - return of(data); + return; } - prompts.sort((a, b) => b.priority - a.priority); + const answers = await lastValueFrom(from(provider(prompts))); + for (const path in answers) { + const pathFragments = path.split('/').slice(1); - return from(provider(prompts)).pipe( - map(answers => { - for (const path in answers) { - const pathFragments = path.split('/').map(pf => { - if (/^\d+$/.test(pf)) { - return pf; - } else { - return '\'' + pf + '\''; - } - }); - - CoreSchemaRegistry._set( - data, - pathFragments.slice(1), - answers[path] as {}, - null, - undefined, - true, - ); - } - - return data; - }), - ); + await CoreSchemaRegistry._set(data, pathFragments, answers[path], null, undefined, true); + } } - private static _set( - // tslint:disable-next-line:no-any + private static async _set( + // eslint-disable-next-line @typescript-eslint/no-explicit-any data: any, fragments: string[], - value: {}, - // tslint:disable-next-line:no-any - parent: any | null = null, + value: unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parent: any = null, parentProperty?: string, force?: boolean, - ): void { - for (let i = 0; i < fragments.length; i++) { - const f = fragments[i]; - - if (f[0] == 'i') { + ): Promise { + for (let index = 0; index < fragments.length; index++) { + const fragment = fragments[index]; + if (/^i\d+$/.test(fragment)) { if (!Array.isArray(data)) { return; } - for (let j = 0; j < data.length; j++) { - CoreSchemaRegistry._set(data[j], fragments.slice(i + 1), value, data, '' + j); - } - - return; - } else if (f.startsWith('key')) { - if (typeof data !== 'object') { - return; + for (let dataIndex = 0; dataIndex < data.length; dataIndex++) { + await CoreSchemaRegistry._set( + data[dataIndex], + fragments.slice(index + 1), + value, + data, + `${dataIndex}`, + ); } - Object.getOwnPropertyNames(data).forEach(property => { - CoreSchemaRegistry._set(data[property], fragments.slice(i + 1), value, data, property); - }); - return; - } else if (f.startsWith('\'') && f[f.length - 1] == '\'') { - const property = f - .slice(1, -1) - .replace(/\\'/g, '\'') - .replace(/\\n/g, '\n') - .replace(/\\r/g, '\r') - .replace(/\\f/g, '\f') - .replace(/\\t/g, '\t'); - - // We know we need an object because the fragment is a property key. - if (!data && parent !== null && parentProperty) { - data = parent[parentProperty] = {}; - } - parent = data; - parentProperty = property; + } - data = data[property]; - } else { - return; + if (!data && parent !== null && parentProperty) { + data = parent[parentProperty] = {}; } + + parent = data; + parentProperty = fragment; + data = data[fragment]; } if (parent && parentProperty && (force || parent[parentProperty] === undefined)) { - parent[parentProperty] = value; + let resolvedValue = typeof value === 'function' ? value() : value; + if (isObservable(resolvedValue)) { + resolvedValue = await lastValueFrom(resolvedValue); + } + + parent[parentProperty] = resolvedValue; } } - private _applySmartDefaults( + private async _applySmartDefaults( data: T, smartDefaults: Map, - ): Observable { - // tslint:disable-next-line:no-any https://github.com/ReactiveX/rxjs/issues/3989 - return (of(data) as any).pipe( - ...[...smartDefaults.entries()].map(([pointer, schema]) => { - return concatMap(data => { - const fragments = JSON.parse(pointer); - const source = this._sourceMap.get((schema as JsonObject).$source as string); - - let value = source ? source(schema) : of(undefined); - - if (!isObservable(value)) { - value = of(value); - } + ): Promise { + for (const [pointer, schema] of smartDefaults.entries()) { + const fragments = JSON.parse(pointer) as string[]; + const source = this._sourceMap.get(schema.$source as string); + if (!source) { + continue; + } + + await CoreSchemaRegistry._set(data, fragments, () => source(schema)); + } + } - return (value as Observable<{}>).pipe( - // Synchronously set the new data at the proper JsonSchema path. - tap(x => CoreSchemaRegistry._set(data, fragments, x)), - // But return the data object. - map(() => data), + useXDeprecatedProvider(onUsage: (message: string) => void): void { + this._ajv.addKeyword({ + keyword: 'x-deprecated', + validate: (schema, _data, _parentSchema, dataCxt) => { + if (schema) { + onUsage( + `Option "${dataCxt?.parentDataProperty}" is deprecated${ + typeof schema == 'string' ? ': ' + schema : '.' + }`, ); - }); - }), - ); + } + + return true; + }, + errors: false, + }); + } + + private normalizeDataPathArr(it: SchemaObjCxt): (number | string)[] { + return it.dataPathArr + .slice(1, it.dataLevel + 1) + .map((p) => (typeof p === 'number' ? p : p.str.replace(/"/g, ''))); } } diff --git a/packages/angular_devkit/core/src/json/schema/registry_spec.ts b/packages/angular_devkit/core/src/json/schema/registry_spec.ts index b221ac9cde91..022588036d3b 100644 --- a/packages/angular_devkit/core/src/json/schema/registry_spec.ts +++ b/packages/angular_devkit/core/src/json/schema/registry_spec.ts @@ -1,54 +1,50 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -// tslint:disable:no-any non-null-operator no-big-function -import { of as observableOf } from 'rxjs'; -import { map, mergeMap } from 'rxjs/operators'; -import { CoreSchemaRegistry } from './registry'; -import { addUndefinedDefaults } from './transforms'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { JsonValue } from '../utils'; +import { SchemaFormat } from './interface'; +import { CoreSchemaRegistry, SchemaValidationException } from './registry'; +import { JsonSchema } from './schema'; +import { addUndefinedDefaults } from './transforms'; describe('CoreSchemaRegistry', () => { - it('works asynchronously', done => { + it('works asynchronously', async () => { const registry = new CoreSchemaRegistry(); registry.addPostTransform(addUndefinedDefaults); - const data: any = {}; // tslint:disable-line:no-any - - registry - .compile({ - properties: { - bool: { type: 'boolean' }, - str: { type: 'string', default: 'someString' }, - obj: { - properties: { - num: { type: 'number' }, - other: { type: 'number', default: 0 }, - }, - }, - tslint: { - $ref: 'http://json.schemastore.org/tslint#', + const data: any = {}; + + const validator = await registry.compile({ + properties: { + bool: { type: 'boolean' }, + str: { type: 'string', default: 'someString' }, + obj: { + properties: { + num: { type: 'number' }, + other: { type: 'number', default: 0 }, }, }, - }) - .pipe( - mergeMap(validator => validator(data)), - map(result => { - expect(result.success).toBe(true); - expect(data.obj.num).toBeUndefined(); - expect(data.tslint).not.toBeUndefined(); - }), - ) - .toPromise().then(done, done.fail); + tslint: { + $ref: 'https://raw.githubusercontent.com/SchemaStore/schemastore/734c0e5/src/schemas/json/tslint.json#', + }, + }, + }); + + const result = await validator(data); + expect(result.success).toBe(true); + expect(data.obj.num).toBeUndefined(); + expect(data.tslint).not.toBeUndefined(); }); - it('supports pre transforms', done => { + it('supports pre transforms', async () => { const registry = new CoreSchemaRegistry(); registry.addPostTransform(addUndefinedDefaults); - const data: any = {}; // tslint:disable-line:no-any + const data = {}; registry.addPreTransform((data, ptr) => { if (ptr == '/') { @@ -58,203 +54,149 @@ describe('CoreSchemaRegistry', () => { return data; }); - registry - .compile({ - properties: { - bool: { type: 'boolean' }, - str: { type: 'string', default: 'someString' }, - obj: { - properties: { - num: { type: 'number' }, - other: { type: 'number', default: 0 }, - }, + const validator = await registry.compile({ + properties: { + bool: { type: 'boolean' }, + str: { type: 'string', default: 'someString' }, + obj: { + properties: { + num: { type: 'number' }, + other: { type: 'number', default: 0 }, }, }, - }) - .pipe( - mergeMap(validator => validator(data)), - map(result => { - const data = result.data as any; - expect(result.success).toBe(true); - expect(data.str).toBe('string'); - expect(data.obj.num).toBeUndefined(); - }), - ) - .toPromise().then(done, done.fail); + }, + }); + + const result = await validator(data); + const resultData = result.data as any; + expect(result.success).toBe(true); + expect(resultData.str).toBe('string'); + expect(resultData.obj.num).toBeUndefined(); }); - it('supports local references', done => { + it('supports local references', async () => { const registry = new CoreSchemaRegistry(); registry.addPostTransform(addUndefinedDefaults); const data = { numbers: { one: 1 } }; - registry - .compile({ - properties: { - numbers: { - type: 'object', - additionalProperties: { '$ref': '#/definitions/myRef' }, - }, - }, - definitions: { - myRef: { type: 'integer' }, + const validator = await registry.compile({ + properties: { + numbers: { + type: 'object', + additionalProperties: { '$ref': '#/definitions/myRef' }, }, - }) - .pipe( - mergeMap(validator => validator(data)), - map(result => { - expect(result.success).toBe(true); - expect(data.numbers.one).not.toBeUndefined(); - }), - ) - .toPromise().then(done, done.fail); + }, + definitions: { + myRef: { type: 'integer' }, + }, + }); + const result = await validator(data); + expect(result.success).toBe(true); + expect(data.numbers.one).not.toBeUndefined(); }); - it('fails on invalid additionalProperties', done => { + it('fails on invalid additionalProperties', async () => { const registry = new CoreSchemaRegistry(); registry.addPostTransform(addUndefinedDefaults); const data = { notNum: 'foo' }; + const validator = await registry.compile({ + properties: { + num: { type: 'number' }, + }, + additionalProperties: false, + }); - registry - .compile({ - properties: { - num: { type: 'number' }, - }, - additionalProperties: false, - }).pipe( - mergeMap(validator => validator(data)), - map(result => { - expect(result.success).toBe(false); - expect(result.errors && result.errors[0].message).toContain( - 'should NOT have additional properties'); - }), - ) - .toPromise().then(done, done.fail); + const result = await validator(data); + expect(result.success).toBe(false); + expect(result.errors && result.errors[0].message).toContain( + 'must NOT have additional properties', + ); }); - it('fails on invalid additionalProperties async', done => { + it('fails on invalid enum value', async () => { const registry = new CoreSchemaRegistry(); registry.addPostTransform(addUndefinedDefaults); - const data = { notNum: 'foo' }; + const data = { packageManager: 'foo' }; - registry - .compile({ - $async: true, - properties: { - num: { type: 'number' }, - }, - additionalProperties: false, - }).pipe( - mergeMap(validator => validator(data)), - map(result => { - expect(result.success).toBe(false); - expect(result.errors && result.errors[0].message).toContain( - 'should NOT have additional properties'); - expect(result.errors && result.errors[0].keyword).toBe('additionalProperties'); - }), - ) - .toPromise().then(done, done.fail); - }); + const validator = await registry.compile({ + properties: { + packageManager: { type: 'string', enum: ['npm', 'yarn', 'pnpm'] }, + }, + additionalProperties: false, + }); + const result = await validator(data); + expect(result.success).toBe(false); + expect(new SchemaValidationException(result.errors).message).toContain( + `Data path "/packageManager" must be equal to one of the allowed values. Allowed values are: "npm", "yarn", "pnpm".`, + ); + }); - // Synchronous failure is only used internally. - // If it's meant to be used externally then this test should change to truly be synchronous - // (i.e. not relyign on the observable). - it('works synchronously', done => { + it('fails on invalid additionalProperties async', async () => { const registry = new CoreSchemaRegistry(); registry.addPostTransform(addUndefinedDefaults); - const data: any = {}; // tslint:disable-line:no-any - let isDone = false; - - registry - .compile({ - properties: { - bool: { type: 'boolean' }, - str: { type: 'string', default: 'someString' }, - obj: { - properties: { - num: { type: 'number' }, - other: { type: 'number', default: 0 }, - }, - }, - }, - }) - .pipe( - mergeMap(validator => validator(data)), - map(result => { - expect(result.success).toBe(true); - expect(data.obj.num).toBeUndefined(); - }), - ) - .subscribe(() => { - isDone = true; - }, done.fail); - - expect(isDone).toBe(true); - done(); + const data = { notNum: 'foo' }; + const validator = await registry.compile({ + $async: true, + properties: { + num: { type: 'number' }, + }, + additionalProperties: false, + }); + const result = await validator(data); + expect(result.success).toBe(false); + expect(result.errors?.[0].message).toContain('must NOT have additional properties'); + expect(result.errors?.[0].keyword).toBe('additionalProperties'); }); - it('supports sync format', done => { + it('supports sync format', async () => { const registry = new CoreSchemaRegistry(); const data = { str: 'hotdog' }; const format = { name: 'is-hotdog', formatter: { - async: false, validate: (str: string) => str === 'hotdog', }, }; registry.addFormat(format); - - registry - .compile({ - properties: { - str: { type: 'string', format: 'is-hotdog' }, - }, - }) - .pipe( - mergeMap(validator => validator(data)), - map(result => { - expect(result.success).toBe(true); - }), - ) - .toPromise().then(done, done.fail); + const validator = await registry.compile({ + properties: { + str: { type: 'string', format: 'is-hotdog' }, + }, + }); + const result = await validator(data); + expect(result.success).toBe(true); }); - it('supports async format', done => { + it('supports async format', async () => { const registry = new CoreSchemaRegistry(); const data = { str: 'hotdog' }; - const format = { + + const format: SchemaFormat = { name: 'is-hotdog', formatter: { async: true, - validate: (str: string) => observableOf(str === 'hotdog'), + validate: async (str: string) => str === 'hotdog', }, }; registry.addFormat(format); - registry - .compile({ - $async: true, - properties: { - str: { type: 'string', format: 'is-hotdog' }, - }, - }) - .pipe( - mergeMap(validator => validator(data)), - map(result => { - expect(result.success).toBe(true); - }), - ) - .toPromise().then(done, done.fail); + const validator = await registry.compile({ + $async: true, + properties: { + str: { type: 'string', format: 'is-hotdog' }, + }, + }); + const result = await validator(data); + expect(result.success).toBe(true); }); - it('shows dataPath and message on error', done => { + it('shows dataPath and message on error', async () => { const registry = new CoreSchemaRegistry(); const data = { hotdot: 'hotdog', banana: 'banana' }; - const format = { + const format: SchemaFormat = { name: 'is-hotdog', formatter: { async: false, @@ -264,27 +206,22 @@ describe('CoreSchemaRegistry', () => { registry.addFormat(format); - registry - .compile({ - properties: { - hotdot: { type: 'string', format: 'is-hotdog' }, - banana: { type: 'string', format: 'is-hotdog' }, - }, - }) - .pipe( - mergeMap(validator => validator(data)), - map(result => { - expect(result.success).toBe(false); - expect(result.errors && result.errors[0]).toBeTruthy(); - expect(result.errors && result.errors[0].keyword).toBe('format'); - expect(result.errors && result.errors[0].dataPath).toBe('.banana'); - expect(result.errors && (result.errors[0].params as any).format).toBe('is-hotdog'); - }), - ) - .toPromise().then(done, done.fail); + const validator = await registry.compile({ + properties: { + hotdot: { type: 'string', format: 'is-hotdog' }, + banana: { type: 'string', format: 'is-hotdog' }, + }, + }); + + const result = await validator(data); + expect(result.success).toBe(false); + expect(result.errors && result.errors[0]).toBeTruthy(); + expect(result.errors && result.errors[0].keyword).toBe('format'); + expect(result.errors && result.errors[0].instancePath).toBe('/banana'); + expect(result.errors && (result.errors[0].params as any).format).toBe('is-hotdog'); }); - it('supports smart defaults', done => { + it('supports smart defaults', async () => { const registry = new CoreSchemaRegistry(); const data: any = { arr: [{}], @@ -306,131 +243,215 @@ describe('CoreSchemaRegistry', () => { return schema['blue']; }); registry.addSmartDefaultProvider('test3', () => { - return [ 1, 2, 3 ]; + return [1, 2, 3]; }); - registry - .compile({ - properties: { - bool: { - $ref: '#/definitions/example', - }, - arr: { - items: { - properties: { - 'test': { - $ref: '#/definitions/other', - }, + const validator = await registry.compile({ + properties: { + bool: { + $ref: '#/definitions/example', + }, + arr: { + items: { + properties: { + 'test': { + $ref: '#/definitions/other', }, }, }, - arr2: { - $ref: '#/definitions/test3', - }, - obj: { - properties: { - deep: { - properties: { - arr: { - $ref: '#/definitions/test3', - }, + }, + arr2: { + $ref: '#/definitions/test3', + }, + obj: { + properties: { + deep: { + properties: { + arr: { + $ref: '#/definitions/test3', }, }, }, }, }, - definitions: { - example: { - type: 'boolean', - $default: { - $source: 'test', - }, + }, + definitions: { + example: { + type: 'boolean', + $default: { + $source: 'test', }, - other: { - type: 'string', - $default: { - $source: 'test2', - blue: 'yep', - }, + }, + other: { + type: 'string', + $default: { + $source: 'test2', + blue: 'yep', }, - test3: { - type: 'array', - $default: { - $source: 'test3', - }, + }, + test3: { + type: 'array', + $default: { + $source: 'test3', + }, + }, + }, + }); + const result = await validator(data); + expect(result.success).toBe(true); + expect(data.bool).toBe(true); + expect(data.arr[0].test).toBe('yep'); + expect(data.arr2).toEqual([1, 2, 3]); + expect(data.obj.deep.arr).toEqual([1, 2, 3]); + }); + + it('does not call smart default provider when value is already defined', async () => { + const registry = new CoreSchemaRegistry(); + const provider = jasmine.createSpy('provider').and.returnValue('default-val'); + registry.addSmartDefaultProvider('test', provider); + + const validator = await registry.compile({ + properties: { + prop: { + type: 'string', + $default: { + $source: 'test', }, }, - }) - .pipe( - mergeMap(validator => validator(data)), - map(result => { - expect(result.success).toBe(true); - expect(data.bool).toBe(true); - expect(data.arr[0].test).toBe('yep'); - expect(data.arr2).toEqual([1, 2, 3]); - expect(data.obj.deep.arr).toEqual([1, 2, 3]); - }), - ) - .toPromise().then(done, done.fail); + }, + }); + + const data = { prop: 'explicit-val' }; + const result = await validator(data); + expect(result.success).toBe(true); + expect(data.prop).toBe('explicit-val'); + expect(provider).not.toHaveBeenCalled(); }); - it('adds undefined properties', done => { + it('works with true as a schema and post-transforms', async () => { const registry = new CoreSchemaRegistry(); registry.addPostTransform(addUndefinedDefaults); - const data: any = {}; // tslint:disable-line:no-any - - registry - .compile({ - properties: { - bool: { type: 'boolean' }, - str: { type: 'string', default: 'someString' }, - obj: { - properties: { - num: { type: 'number' }, - other: { type: 'number', default: 0 }, + const data = { a: 1, b: 2 }; + + const validate = await registry.compile(true); + const result = await validate(data); + + expect(result.success).toBe(true); + expect(result.data).toBe(data); + }); + + it('adds deprecated options usage', async () => { + const registry = new CoreSchemaRegistry(); + const deprecatedMessages: string[] = []; + registry.useXDeprecatedProvider((m) => deprecatedMessages.push(m)); + + const data = { + foo: true, + bar: true, + bat: true, + }; + + const validator = await registry.compile({ + properties: { + foo: { type: 'boolean', 'x-deprecated': 'Use bar instead.' }, + bar: { type: 'boolean', 'x-deprecated': true }, + buz: { type: 'boolean', 'x-deprecated': true }, + bat: { type: 'boolean', 'x-deprecated': false }, + }, + }); + const result = await validator(data); + expect(deprecatedMessages.length).toBe(2); + expect(deprecatedMessages[0]).toBe('Option "foo" is deprecated: Use bar instead.'); + expect(deprecatedMessages[1]).toBe('Option "bar" is deprecated.'); + expect(result.success).toBe(true, result.errors); + }); + + describe('error messages', () => { + async function messagesFor(schema: JsonSchema, data: JsonValue): Promise { + const registry = new CoreSchemaRegistry(); + const validator = await registry.compile(schema); + const result = await validator(data); + expect(result.success).toBe(false); + + return SchemaValidationException.createMessages(result.errors); + } + + it('names an unknown option and the options that are valid there', async () => { + const messages = await messagesFor( + { + properties: { version: { type: 'number' }, projects: { type: 'object' } }, + additionalProperties: false, + }, + { allowedCommonJsDependencies: [] }, + ); + + expect(messages).toEqual([ + 'Unknown option "allowedCommonJsDependencies". Valid options are: version, projects.', + ]); + }); + + it('points at the object an unknown option was found in', async () => { + const messages = await messagesFor( + { + properties: { + cli: { + type: 'object', + properties: { cache: { type: 'object' }, packageManager: { type: 'string' } }, + additionalProperties: false, }, }, - objAllOk: { - allOf: [ - { type: 'object' }, - ], - }, - objAllBad: { - allOf: [ - { type: 'object' }, - { type: 'number' }, - ], - }, - objOne: { - oneOf: [ - { type: 'object' }, - ], - }, - objNotOk: { - not: { not: { type: 'object' } }, + }, + { cli: { completion: true } }, + ); + + expect(messages).toEqual([ + 'Unknown option "completion" at "/cli". Valid options are: cache, packageManager.', + ]); + }); + + it('looks through a $ref for the valid options', async () => { + const messages = await messagesFor( + { + $ref: '#/definitions/global', + definitions: { + global: { + type: 'object', + properties: { cli: { type: 'object' }, schematics: { type: 'object' } }, + additionalProperties: false, + }, }, - objNotBad: { - type: 'object', - not: { type: 'object' }, + }, + { version: 1 }, + ); + + expect(messages).toEqual(['Unknown option "version". Valid options are: cli, schematics.']); + }); + + it('omits the valid options when the schema does not list any', async () => { + const messages = await messagesFor( + { + $ref: '#/definitions/cli', + definitions: { + cli: { + type: 'object', + patternProperties: { '^x-': { type: 'string' } }, + additionalProperties: false, + }, }, }, - }) - .pipe( - mergeMap(validator => validator(data)), - map(result => { - expect(result.success).toBe(true); - expect(data.bool).toBeUndefined(); - expect(data.str).toBe('someString'); - expect(data.obj.num).toBeUndefined(); - expect(data.obj.other).toBe(0); - expect(data.objAllOk).toEqual({}); - expect(data.objOne).toEqual({}); - expect(data.objAllBad).toBeUndefined(); - expect(data.objNotOk).toEqual({}); - expect(data.objNotBad).toBeUndefined(); - }), - ) - .toPromise().then(done, done.fail); - }); + { completion: true }, + ); + + expect(messages).toEqual(['Unknown option "completion".']); + }); + it('leaves an error that is not about an unknown option alone', async () => { + const messages = await messagesFor( + { properties: { outputPath: { type: 'string' } } }, + { outputPath: 42 }, + ); + + expect(messages).toEqual(['Data path "/outputPath" must be string.']); + }); + }); }); diff --git a/packages/angular_devkit/core/src/json/schema/schema.ts b/packages/angular_devkit/core/src/json/schema/schema.ts new file mode 100644 index 000000000000..1444d5aef1a9 --- /dev/null +++ b/packages/angular_devkit/core/src/json/schema/schema.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 { JsonObject, JsonValue, isJsonObject } from '../utils'; + +/** + * A specialized interface for JsonSchema (to come). JsonSchemas are also JsonObject. + * + * @public + */ +export type JsonSchema = JsonObject | boolean; + +export function isJsonSchema(value: unknown): value is JsonSchema { + return isJsonObject(value as JsonValue) || value === false || value === true; +} + +/** + * Return a schema that is the merge of all subschemas, ie. it should validate all the schemas + * that were passed in. It is possible to make an invalid schema this way, e.g. by using + * `mergeSchemas({ type: 'number' }, { type: 'string' })`, which will never validate. + * @param schemas All schemas to be merged. + */ +export function mergeSchemas(...schemas: (JsonSchema | undefined)[]): JsonSchema { + return schemas.reduce((prev, curr) => { + if (curr === undefined) { + return prev; + } + + if (prev === false || curr === false) { + return false; + } else if (prev === true) { + return curr; + } else if (curr === true) { + return prev; + } else if (Array.isArray(prev.allOf)) { + if (Array.isArray(curr.allOf)) { + return { ...prev, allOf: [...prev.allOf, ...curr.allOf] }; + } else { + return { ...prev, allOf: [...prev.allOf, curr] }; + } + } else if (Array.isArray(curr.allOf)) { + return { ...prev, allOf: [prev, ...curr.allOf] }; + } else { + return { ...prev, allOf: [prev, curr] }; + } + }, true); +} diff --git a/packages/angular_devkit/core/src/json/schema/transforms.ts b/packages/angular_devkit/core/src/json/schema/transforms.ts index 9c6694e4e32e..1c97b7d1e9cc 100644 --- a/packages/angular_devkit/core/src/json/schema/transforms.ts +++ b/packages/angular_devkit/core/src/json/schema/transforms.ts @@ -1,23 +1,44 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { JsonObject, JsonValue, isJsonObject } from '../interface'; + +import { JsonObject, JsonValue, isJsonArray, isJsonObject } from '../utils'; import { JsonPointer } from './interface'; +import { JsonSchema } from './schema'; import { getTypesOfSchema } from './utility'; +export function addUndefinedObjectDefaults( + value: JsonValue, + _pointer: JsonPointer, + schema?: JsonSchema, +): JsonValue { + return transformUndefined(value, _pointer, schema, true); +} + export function addUndefinedDefaults( value: JsonValue, _pointer: JsonPointer, - schema?: JsonObject, + schema?: JsonSchema, ): JsonValue { - if (!schema) { + return transformUndefined(value, _pointer, schema, false); +} + +function transformUndefined( + value: JsonValue, + _pointer: JsonPointer, + schema?: JsonSchema, + onlyObjects?: boolean, +): JsonValue { + if (typeof schema === 'boolean' || schema === undefined) { return value; } + value ??= schema.default; + const types = getTypesOfSchema(schema); if (types.size === 0) { return value; @@ -41,7 +62,7 @@ export function addUndefinedDefaults( return value; } - if (type === 'array') { + if (!onlyObjects && type === 'array') { return value == undefined ? [] : value; } @@ -59,17 +80,40 @@ export function addUndefinedDefaults( return newValue; } - for (const propName of Object.getOwnPropertyNames(schema.properties)) { - if (propName in newValue) { - continue; - } else if (propName == '$schema') { + for (const [propName, schemaObject] of Object.entries(schema.properties)) { + if (propName === '$schema' || !isJsonObject(schemaObject)) { continue; } - // TODO: Does not currently handle more complex schemas (oneOf/anyOf/etc.) - const defaultValue = (schema.properties[propName] as JsonObject).default; + const value = newValue[propName]; + if (value === undefined) { + newValue[propName] = schemaObject.default; + } else if (isJsonObject(value)) { + // Basic support for oneOf and anyOf. + const propertySchemas = schemaObject.oneOf || schemaObject.anyOf; + const allProperties = Object.keys(value); + // Locate a schema which declares all the properties that the object contains. + const adjustedSchema = + isJsonArray(propertySchemas) && + propertySchemas.find((s) => { + if (!isJsonObject(s)) { + return false; + } - newValue[propName] = defaultValue; + const schemaType = getTypesOfSchema(s); + if (schemaType.size === 1 && schemaType.has('object') && isJsonObject(s.properties)) { + const properties = Object.keys(s.properties); + + return allProperties.every((key) => properties.includes(key)); + } + + return false; + }); + + if (adjustedSchema && isJsonObject(adjustedSchema)) { + newValue[propName] = transformUndefined(value, _pointer, adjustedSchema, onlyObjects); + } + } } return newValue; diff --git a/packages/angular_devkit/core/src/json/schema/transforms_spec.ts b/packages/angular_devkit/core/src/json/schema/transforms_spec.ts new file mode 100644 index 000000000000..aa3de8c82471 --- /dev/null +++ b/packages/angular_devkit/core/src/json/schema/transforms_spec.ts @@ -0,0 +1,252 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { CoreSchemaRegistry } from './registry'; +import { addUndefinedDefaults } from './transforms'; + +describe('addUndefinedDefaults', () => { + it('should add defaults to undefined properties (1)', async () => { + const registry = new CoreSchemaRegistry(); + registry.addPreTransform(addUndefinedDefaults); + const data: any = {}; // eslint-disable-line @typescript-eslint/no-explicit-any + + const validator = await registry.compile({ + properties: { + bool: { type: 'boolean' }, + str: { type: 'string', default: 'someString' }, + obj: { + properties: { + num: { type: 'number' }, + other: { type: 'number', default: 0 }, + }, + }, + objAllOk: { + allOf: [{ type: 'object' }], + }, + objAllBad: { + allOf: [{ type: 'object' }, { type: 'number' }], + }, + objOne: { + oneOf: [{ type: 'object' }], + }, + objNotOk: { + not: { not: { type: 'object' } }, + }, + objNotBad: { + type: 'object', + not: { type: 'object' }, + }, + }, + }); + + const result = await validator(data); + + expect(result.success).toBeTrue(); + expect(data.bool).toBeUndefined(); + expect(data.str).toBe('someString'); + expect(data.obj.num).toBeUndefined(); + expect(data.obj.other).toBe(0); + expect(data.objAllOk).toEqual({}); + expect(data.objOne).toEqual({}); + expect(data.objAllBad).toBeUndefined(); + expect(data.objNotOk).toEqual({}); + expect(data.objNotBad).toBeUndefined(); + }); + + it('should add defaults to undefined properties (2)', async () => { + const registry = new CoreSchemaRegistry(); + registry.addPreTransform(addUndefinedDefaults); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data: any = { + bool: undefined, + str: undefined, + obj: { + num: undefined, + }, + }; + + const validator = await registry.compile({ + properties: { + bool: { type: 'boolean', default: true }, + str: { type: 'string', default: 'someString' }, + obj: { + properties: { + num: { type: 'number', default: 0 }, + }, + }, + }, + }); + + const result = await validator(data); + + expect(result.success).toBeTrue(); + expect(data.bool).toBeTrue(); + expect(data.str).toBe('someString'); + expect(data.obj.num).toBe(0); + }); + + it('should add defaults to undefined properties when using oneOf', async () => { + const registry = new CoreSchemaRegistry(); + registry.addPreTransform(addUndefinedDefaults); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dataNoObj: any = { + bool: undefined, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dataObj: any = { + bool: undefined, + obj: { + a: false, + }, + }; + + const validator = await registry.compile({ + properties: { + bool: { type: 'boolean', default: true }, + obj: { + default: true, + oneOf: [ + { + type: 'object', + properties: { + a: { type: 'boolean', default: true }, + b: { type: 'boolean', default: true }, + c: { type: 'boolean', default: false }, + }, + }, + { + type: 'boolean', + }, + ], + }, + noDefaultOneOf: { + oneOf: [ + { + type: 'object', + properties: { + a: { type: 'boolean', default: true }, + b: { type: 'boolean', default: true }, + c: { type: 'boolean', default: false }, + }, + }, + { + type: 'boolean', + }, + ], + }, + }, + }); + + const result1 = await validator(dataNoObj); + expect(result1.success).toBeTrue(); + expect(dataNoObj.bool).toBeTrue(); + expect(dataNoObj.obj).toBeTrue(); + expect(dataNoObj.noDefaultOneOf).toBeUndefined(); + + const result2 = await validator(dataObj); + expect(result2.success).toBeTrue(); + expect(dataObj.bool).toBeTrue(); + expect(dataObj.obj.a).toBeFalse(); + expect(dataObj.obj.b).toBeTrue(); + expect(dataObj.obj.c).toBeFalse(); + }); + + it('should add defaults to undefined properties when using anyOf', async () => { + const registry = new CoreSchemaRegistry(); + registry.addPreTransform(addUndefinedDefaults); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dataNoObj: any = { + bool: undefined, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dataObj: any = { + bool: undefined, + obj: { + a: false, + }, + }; + + const validator = await registry.compile({ + properties: { + bool: { type: 'boolean', default: true }, + obj: { + default: true, + anyOf: [ + { + type: 'object', + properties: { + d: { type: 'boolean', default: false }, + }, + }, + { + type: 'object', + properties: { + a: { type: 'boolean', default: true }, + b: { type: 'boolean', default: true }, + c: { type: 'boolean', default: false }, + }, + }, + { + type: 'boolean', + }, + ], + }, + }, + }); + + const result1 = await validator(dataNoObj); + expect(result1.success).toBeTrue(); + expect(dataNoObj.bool).toBeTrue(); + expect(dataNoObj.obj).toBeTrue(); + + const result2 = await validator(dataObj); + expect(result2.success).toBeTrue(); + expect(dataObj.bool).toBeTrue(); + expect(dataObj.obj.a).toBeFalse(); + expect(dataObj.obj.b).toBeTrue(); + expect(dataObj.obj.c).toBeFalse(); + }); + + it('should add defaults to undefined properties when using $refs', async () => { + const registry = new CoreSchemaRegistry(); + registry.addPreTransform(addUndefinedDefaults); + const dataNoObj: Record = {}; + + const dataObj: Record = { + boolRef: true, + }; + + const validator = await registry.compile({ + definitions: { + boolRef: { + default: false, + type: 'boolean', + }, + }, + properties: { + bool: { + default: false, + type: 'boolean', + }, + boolRef: { + $ref: '#/definitions/boolRef', + }, + }, + }); + + const result1 = await validator(dataNoObj); + expect(result1.success).toBeTrue(); + expect(dataNoObj['bool']).toBeFalse(); + expect(dataNoObj['boolRef']).toBeFalse(); + + const result2 = await validator(dataObj); + expect(result2.success).toBeTrue(); + expect(dataObj['bool']).toBeFalse(); + expect(dataObj['boolRef']).toBeTrue(); + }); +}); diff --git a/packages/angular_devkit/core/src/json/schema/utility.ts b/packages/angular_devkit/core/src/json/schema/utility.ts index 8c0cf990f1ac..83bc336e0952 100644 --- a/packages/angular_devkit/core/src/json/schema/utility.ts +++ b/packages/angular_devkit/core/src/json/schema/utility.ts @@ -1,16 +1,17 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { JsonObject, isJsonArray, isJsonObject } from '../interface'; +import { JsonObject, isJsonArray, isJsonObject } from '../utils'; +import { JsonSchema } from './schema'; const allTypes = ['string', 'integer', 'number', 'object', 'array', 'boolean', 'null']; -export function getTypesOfSchema(schema: JsonObject | true): Set { +export function getTypesOfSchema(schema: JsonSchema): Set { if (!schema) { return new Set(); } @@ -52,13 +53,13 @@ export function getTypesOfSchema(schema: JsonObject | true): Set { if (isJsonObject(schema.not)) { const notTypes = getTypesOfSchema(schema.not); - potentials = new Set([...potentials].filter(p => !notTypes.has(p))); + potentials = potentials.difference(notTypes); } if (Array.isArray(schema.allOf)) { for (const sub of schema.allOf) { const types = getTypesOfSchema(sub as JsonObject); - potentials = new Set([...potentials].filter(p => types.has(p))); + potentials = new Set([...types].filter((t) => potentials.has(t))); } } @@ -66,18 +67,18 @@ export function getTypesOfSchema(schema: JsonObject | true): Set { let options = new Set(); for (const sub of schema.oneOf) { const types = getTypesOfSchema(sub as JsonObject); - options = new Set([...options, ...types]); + options = options.union(types); } - potentials = new Set([...potentials].filter(p => options.has(p))); + potentials = new Set([...options].filter((o) => potentials.has(o))); } if (Array.isArray(schema.anyOf)) { let options = new Set(); for (const sub of schema.anyOf) { const types = getTypesOfSchema(sub as JsonObject); - options = new Set([...options, ...types]); + options = options.union(types); } - potentials = new Set([...potentials].filter(p => options.has(p))); + potentials = new Set([...options].filter((o) => potentials.has(o))); } if (schema.properties) { diff --git a/packages/angular_devkit/core/src/json/schema/visitor.ts b/packages/angular_devkit/core/src/json/schema/visitor.ts index c0d68b2778dc..f7094615e56e 100644 --- a/packages/angular_devkit/core/src/json/schema/visitor.ts +++ b/packages/angular_devkit/core/src/json/schema/visitor.ts @@ -1,34 +1,42 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { Observable, concat, from, of as observableOf } from 'rxjs'; -import { concatMap, ignoreElements, mergeMap, tap } from 'rxjs/operators'; -import { isObservable } from '../../utils'; -import { JsonArray, JsonObject, JsonValue } from '../interface'; + +import { + Observable, + concat, + concatMap, + from, + ignoreElements, + isObservable, + mergeMap, + of as observableOf, + tap, +} from 'rxjs'; +import { JsonArray, JsonObject, JsonValue } from '../utils'; import { JsonPointer, JsonSchemaVisitor, JsonVisitor } from './interface'; import { buildJsonPointer, joinJsonPointer } from './pointer'; - +import { JsonSchema } from './schema'; export interface ReferenceResolver { - (ref: string, context?: ContextT): { context?: ContextT, schema?: JsonObject }; + (ref: string, context?: ContextT): { context?: ContextT; schema?: JsonObject }; } -function _getObjectSubSchema( - schema: JsonObject | undefined, - key: string, -): JsonObject | undefined { +function _getObjectSubSchema(schema: JsonSchema | undefined, key: string): JsonObject | undefined { if (typeof schema !== 'object' || schema === null) { return undefined; } // Is it an object schema? if (typeof schema.properties == 'object' || schema.type == 'object') { - if (typeof schema.properties == 'object' - && typeof (schema.properties as JsonObject)[key] == 'object') { + if ( + typeof schema.properties == 'object' && + typeof (schema.properties as JsonObject)[key] == 'object' + ) { return (schema.properties as JsonObject)[key] as JsonObject; } if (typeof schema.additionalProperties == 'object') { @@ -50,26 +58,28 @@ function _visitJsonRecursive( json: JsonValue, visitor: JsonVisitor, ptr: JsonPointer, - schema?: JsonObject, + schema?: JsonSchema, refResolver?: ReferenceResolver, - context?: ContextT, // tslint:disable-line:no-any + context?: ContextT, root?: JsonObject | JsonArray, ): Observable { + if (schema === true || schema === false) { + // There's no schema definition, so just visit the JSON recursively. + schema = undefined; + } + // eslint-disable-next-line no-prototype-builtins if (schema && schema.hasOwnProperty('$ref') && typeof schema['$ref'] == 'string') { if (refResolver) { - const resolved = refResolver(schema['$ref'] as string, context); + const resolved = refResolver(schema['$ref'], context); schema = resolved.schema; context = resolved.context; } } - const value = visitor(json, ptr, schema, root); + const value = visitor(json, ptr, schema as JsonObject, root); - return (isObservable(value) - ? value as Observable - : observableOf(value as JsonValue) - ).pipe( - concatMap((value: JsonValue) => { + return (isObservable(value) ? value : observableOf(value)).pipe( + concatMap((value) => { if (Array.isArray(value)) { return concat( from(value).pipe( @@ -82,7 +92,7 @@ function _visitJsonRecursive( refResolver, context, root || value, - ).pipe(tap(x => value[i] = x)); + ).pipe(tap((x) => (value[i] = x))); }), ignoreElements(), ), @@ -91,7 +101,7 @@ function _visitJsonRecursive( } else if (typeof value == 'object' && value !== null) { return concat( from(Object.getOwnPropertyNames(value)).pipe( - mergeMap(key => { + mergeMap((key) => { return _visitJsonRecursive( value[key], visitor, @@ -100,11 +110,18 @@ function _visitJsonRecursive( refResolver, context, root || value, - ).pipe(tap(x => value[key] = x)); + ).pipe( + tap((x) => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor && descriptor.writable && value[key] !== x) { + value[key] = x; + } + }), + ); }), ignoreElements(), - ), - observableOf(value), + ), + observableOf(value), ); } else { return observableOf(value); @@ -131,15 +148,19 @@ function _visitJsonRecursive( export function visitJson( json: JsonValue, visitor: JsonVisitor, - schema?: JsonObject, + schema?: JsonSchema, refResolver?: ReferenceResolver, - context?: ContextT, // tslint:disable-line:no-any + context?: ContextT, ): Observable { return _visitJsonRecursive(json, visitor, buildJsonPointer([]), schema, refResolver, context); } +export function visitJsonSchema(schema: JsonSchema, visitor: JsonSchemaVisitor): void { + if (schema === false || schema === true) { + // Nothing to visit. + return; + } -export function visitJsonSchema(schema: JsonObject, visitor: JsonSchemaVisitor) { const keywords = { additionalItems: true, items: true, diff --git a/packages/angular_devkit/core/src/json/schema/visitor_spec.ts b/packages/angular_devkit/core/src/json/schema/visitor_spec.ts index e938ccd56216..38cde5732e3e 100644 --- a/packages/angular_devkit/core/src/json/schema/visitor_spec.ts +++ b/packages/angular_devkit/core/src/json/schema/visitor_spec.ts @@ -1,37 +1,36 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { Observable, from } from 'rxjs'; import { JsonObject, JsonValue } from '..'; import { visitJson } from './visitor'; - function syncObs(obs: Observable): T { let value: T; let set = false; obs - .forEach(x => { + .forEach((x) => { if (set) { throw new Error('Multiple value.'); } value = x; set = true; }) - .catch(err => fail(err)); + .catch((err) => fail(err)); if (!set) { throw new Error('Async observable.'); } - return value !; // tslint:disable-line:no-non-null-assertion + return value!; // eslint-disable-line @typescript-eslint/no-non-null-assertion } - describe('visitJson', () => { it('works to replace the root', () => { const json = { a: 1 }; @@ -45,13 +44,15 @@ describe('visitJson', () => { const json = { a: 1 }; const newJson = { b: 'hello ' }; - const result = syncObs(visitJson(json, value => { - if (typeof value == 'object') { - return newJson; - } else { - return value + 'world'; - } - })); + const result = syncObs( + visitJson(json, (value) => { + if (typeof value == 'object') { + return newJson; + } else { + return value + 'world'; + } + }), + ); expect(result).toEqual({ b: 'hello world' }); expect(newJson).toEqual({ b: 'hello world' }); }); @@ -62,32 +63,34 @@ describe('visitJson', () => { const newJson2 = { c: [] }; const newJson3 = [1, 2, 3]; - const result = syncObs(visitJson(json, (value, ptr) => { - if (ptr.endsWith('a')) { - return newJson; - } else if (ptr.endsWith('b')) { - return newJson2; - } else if (ptr.endsWith('c')) { - return newJson3; - } else if (typeof value == 'number') { - return '_' + value; - } else if (ptr == '/') { - return value; - } else { - return 'abc'; - } - })); + const result = syncObs( + visitJson(json, (value, ptr) => { + if (ptr.endsWith('a')) { + return newJson; + } else if (ptr.endsWith('b')) { + return newJson2; + } else if (ptr.endsWith('c')) { + return newJson3; + } else if (typeof value == 'number') { + return '_' + value; + } else if (ptr == '/') { + return value; + } else { + return 'abc'; + } + }), + ); expect(result).toEqual({ a: { b: { c: ['_1', '_2', '_3'] } } }); }); - it('goes through all replacements recursively (async)', done => { + it('goes through all replacements recursively (async)', async () => { const json = { a: 1 }; const newJson = { b: '' }; const newJson2 = { c: [] }; const newJson3 = [1, 2, 3]; - visitJson(json, (value, ptr) => { + const result = await visitJson(json, (value, ptr) => { if (ptr.endsWith('a')) { return from(Promise.resolve(newJson)); } else if (ptr.endsWith('b')) { @@ -101,13 +104,9 @@ describe('visitJson', () => { } else { return from(Promise.resolve('abc')); } - }).toPromise().then( - result => { - expect(result).toEqual({ a: { b: { c: ['_1', '_2', '_3'] } } }); - done(); - }, - done.fail, - ); + }).toPromise(); + + expect(result).toEqual({ a: { b: { c: ['_1', '_2', '_3'] } } }); }); it('works with schema', () => { diff --git a/packages/angular_devkit/core/src/json/utils.ts b/packages/angular_devkit/core/src/json/utils.ts new file mode 100644 index 000000000000..8233fe9670a2 --- /dev/null +++ b/packages/angular_devkit/core/src/json/utils.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface JsonArray extends Array {} + +export interface JsonObject { + [prop: string]: JsonValue; +} + +export type JsonValue = boolean | string | number | JsonArray | JsonObject | null; + +export function isJsonObject(value: JsonValue): value is JsonObject { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +export function isJsonArray(value: JsonValue): value is JsonArray { + return Array.isArray(value); +} diff --git a/packages/angular_devkit/core/src/logger/indent.ts b/packages/angular_devkit/core/src/logger/indent.ts index 7eb1958fc855..e1174137cfca 100644 --- a/packages/angular_devkit/core/src/logger/indent.ts +++ b/packages/angular_devkit/core/src/logger/indent.ts @@ -1,13 +1,13 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { map } from 'rxjs/operators'; -import { Logger } from './logger'; +import { map } from 'rxjs'; +import { Logger } from './logger'; /** * Keep an map of indentation => array of indentations based on the level. @@ -16,8 +16,7 @@ import { Logger } from './logger'; * loggers. Also, string concatenation is expensive so performing concats for every log entries * is expensive; this alleviates it. */ -const indentationMap: {[indentationType: string]: string[]} = {}; - +const indentationMap: { [indentationType: string]: string[] } = {}; export class IndentLogger extends Logger { constructor(name: string, parent: Logger | null = null, indentation = ' ') { @@ -26,19 +25,21 @@ export class IndentLogger extends Logger { indentationMap[indentation] = indentationMap[indentation] || ['']; const indentMap = indentationMap[indentation]; - this._observable = this._observable.pipe(map(entry => { - const l = entry.path.filter(x => !!x).length; - if (l >= indentMap.length) { - let current = indentMap[indentMap.length - 1]; - while (l >= indentMap.length) { - current += indentation; - indentMap.push(current); + this._observable = this._observable.pipe( + map((entry) => { + const l = entry.path.filter((x) => !!x).length; + if (l >= indentMap.length) { + let current = indentMap[indentMap.length - 1]; + while (l >= indentMap.length) { + current += indentation; + indentMap.push(current); + } } - } - entry.message = indentMap[l] + entry.message.split(/\n/).join('\n' + indentMap[l]); + entry.message = indentMap[l] + entry.message.split(/\n/).join('\n' + indentMap[l]); - return entry; - })); + return entry; + }), + ); } } diff --git a/packages/angular_devkit/core/src/logger/indent_spec.ts b/packages/angular_devkit/core/src/logger/indent_spec.ts index a35ed3307a96..c457bb1619d2 100644 --- a/packages/angular_devkit/core/src/logger/indent_spec.ts +++ b/packages/angular_devkit/core/src/logger/indent_spec.ts @@ -1,21 +1,20 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -// tslint:disable:no-any -import { toArray } from 'rxjs/operators'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { lastValueFrom, toArray } from 'rxjs'; import { IndentLogger } from './indent'; import { LogEntry, Logger } from './logger'; - describe('IndentSpec', () => { it('works', (done: DoneFn) => { const logger = new IndentLogger('test'); - logger.pipe(toArray()) - .toPromise() + lastValueFrom(logger.pipe(toArray())) .then((observed: LogEntry[]) => { expect(observed).toEqual([ jasmine.objectContaining({ message: 'test', level: 'info', name: 'test' }) as any, @@ -25,7 +24,10 @@ describe('IndentSpec', () => { jasmine.objectContaining({ message: 'test5', level: 'info', name: 'test' }) as any, ]); }) - .then(() => done(), err => done.fail(err)); + .then( + () => done(), + (err) => done.fail(err), + ); const logger2 = new Logger('test2', logger); const logger3 = new Logger('test3', logger2); const logger4 = new Logger('test4', logger); diff --git a/packages/angular_devkit/core/src/logger/index.ts b/packages/angular_devkit/core/src/logger/index.ts index 63bc0860b0c8..3615d593f1da 100644 --- a/packages/angular_devkit/core/src/logger/index.ts +++ b/packages/angular_devkit/core/src/logger/index.ts @@ -1,10 +1,11 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ + export * from './indent'; export * from './level'; export * from './logger'; diff --git a/packages/angular_devkit/core/src/logger/level.ts b/packages/angular_devkit/core/src/logger/level.ts index 7de3255d90c5..244c48fe56d4 100644 --- a/packages/angular_devkit/core/src/logger/level.ts +++ b/packages/angular_devkit/core/src/logger/level.ts @@ -1,33 +1,34 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { JsonObject } from '../json/interface'; + +import { JsonObject } from '../json/utils'; import { LogLevel, Logger } from './logger'; export class LevelTransformLogger extends Logger { constructor( - public readonly name: string, - public readonly parent: Logger | null = null, + public override readonly name: string, + public override readonly parent: Logger | null, public readonly levelTransform: (level: LogLevel) => LogLevel, ) { super(name, parent); } - log(level: LogLevel, message: string, metadata: JsonObject = {}): void { + override log(level: LogLevel, message: string, metadata: JsonObject = {}): void { return super.log(this.levelTransform(level), message, metadata); } - createChild(name: string): Logger { + override createChild(name: string): Logger { return new LevelTransformLogger(name, this, this.levelTransform); } } export class LevelCapLogger extends LevelTransformLogger { - static levelMap: {[cap: string]: {[level: string]: string}} = { + static levelMap: { [cap: string]: { [level: string]: string } } = { debug: { debug: 'debug', info: 'debug', warn: 'debug', error: 'debug', fatal: 'debug' }, info: { debug: 'debug', info: 'info', warn: 'info', error: 'info', fatal: 'info' }, warn: { debug: 'debug', info: 'info', warn: 'warn', error: 'warn', fatal: 'warn' }, @@ -36,8 +37,8 @@ export class LevelCapLogger extends LevelTransformLogger { }; constructor( - public readonly name: string, - public readonly parent: Logger | null = null, + public override readonly name: string, + public override readonly parent: Logger | null, public readonly levelCap: LogLevel, ) { super(name, parent, (level: LogLevel) => { diff --git a/packages/angular_devkit/core/src/logger/logger.ts b/packages/angular_devkit/core/src/logger/logger.ts index e31cc7ce895b..256e6b219e5e 100644 --- a/packages/angular_devkit/core/src/logger/logger.ts +++ b/packages/angular_devkit/core/src/logger/logger.ts @@ -1,13 +1,13 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { Observable, Operator, PartialObserver, Subject, Subscription } from 'rxjs'; -import { JsonObject } from '../json/interface'; +import { EMPTY, Observable, Operator, PartialObserver, Subject, Subscription } from 'rxjs'; +import { JsonObject } from '../json/utils'; export interface LoggerMetadata extends JsonObject { name: string; @@ -30,39 +30,47 @@ export interface LoggerApi { export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; - export class Logger extends Observable implements LoggerApi { protected readonly _subject: Subject = new Subject(); protected _metadata: LoggerMetadata; - private _obs: Observable; - private _subscription: Subscription | null; + private _obs: Observable = EMPTY; + private _subscription: Subscription | null = null; - protected get _observable() { return this._obs; } + protected get _observable() { + return this._obs; + } protected set _observable(v: Observable) { if (this._subscription) { this._subscription.unsubscribe(); } this._obs = v; if (this.parent) { - this._subscription = this.subscribe((value: LogEntry) => { - if (this.parent) { - this.parent._subject.next(value); - } - }, (error: Error) => { - if (this.parent) { - this.parent._subject.error(error); - } - }, () => { - if (this._subscription) { - this._subscription.unsubscribe(); - } - this._subscription = null; - }); + this._subscription = this.subscribe( + (value: LogEntry) => { + if (this.parent) { + this.parent._subject.next(value); + } + }, + (error: Error) => { + if (this.parent) { + this.parent._subject.error(error); + } + }, + () => { + if (this._subscription) { + this._subscription.unsubscribe(); + } + this._subscription = null; + }, + ); } } - constructor(public readonly name: string, public readonly parent: Logger | null = null) { + constructor( + public readonly name: string, + public readonly parent: Logger | null = null, + ) { super(); const path: string[] = []; @@ -93,55 +101,74 @@ export class Logger extends Observable implements LoggerApi { }; } - createChild(name: string) { + createChild(name: string): Logger { return new (this.constructor as typeof Logger)(name, this); } - complete() { + complete(): void { this._subject.complete(); } log(level: LogLevel, message: string, metadata: JsonObject = {}): void { - const entry: LogEntry = Object.assign({}, this._metadata, metadata, { - level, message, timestamp: +Date.now(), + const entry: LogEntry = Object.assign({}, metadata, this._metadata, { + level, + message, + timestamp: +Date.now(), }); this._subject.next(entry); } + next(entry: LogEntry): void { + this._subject.next(entry); + } - debug(message: string, metadata: JsonObject = {}) { - return this.log('debug', message, metadata); + debug(message: string, metadata: JsonObject = {}): void { + this.log('debug', message, metadata); } - info(message: string, metadata: JsonObject = {}) { - return this.log('info', message, metadata); + info(message: string, metadata: JsonObject = {}): void { + this.log('info', message, metadata); } - warn(message: string, metadata: JsonObject = {}) { - return this.log('warn', message, metadata); + warn(message: string, metadata: JsonObject = {}): void { + this.log('warn', message, metadata); } - error(message: string, metadata: JsonObject = {}) { - return this.log('error', message, metadata); + error(message: string, metadata: JsonObject = {}): void { + this.log('error', message, metadata); } - fatal(message: string, metadata: JsonObject = {}) { - return this.log('fatal', message, metadata); + fatal(message: string, metadata: JsonObject = {}): void { + this.log('fatal', message, metadata); } - toString() { + override toString() { return ``; } - lift(operator: Operator): Observable { + override lift(operator: Operator): Observable { return this._observable.lift(operator); } - subscribe(): Subscription; - subscribe(observer: PartialObserver): Subscription; - subscribe(next?: (value: LogEntry) => void, error?: (error: Error) => void, - complete?: () => void): Subscription; - subscribe(_observerOrNext?: PartialObserver | ((value: LogEntry) => void), - _error?: (error: Error) => void, - _complete?: () => void): Subscription { - return this._observable.subscribe.apply(this._observable, arguments); + override subscribe(): Subscription; + override subscribe(observer: PartialObserver): Subscription; + override subscribe( + next?: (value: LogEntry) => void, + error?: (error: Error) => void, + complete?: () => void, + ): Subscription; + override subscribe( + _observerOrNext?: PartialObserver | ((value: LogEntry) => void), + _error?: (error: Error) => void, + _complete?: () => void, + ): Subscription { + // eslint-disable-next-line prefer-spread + return this._observable.subscribe.apply( + this._observable, + // eslint-disable-next-line prefer-rest-params + arguments as unknown as Parameters['subscribe']>, + ); } - forEach(next: (value: LogEntry) => void, PromiseCtor?: typeof Promise): Promise { - return this._observable.forEach(next, PromiseCtor); + + override forEach( + next: (value: LogEntry) => void, + promiseCtor: PromiseConstructorLike = Promise, + ): Promise { + return this._observable.forEach(next, promiseCtor); } } diff --git a/packages/angular_devkit/core/src/logger/logger_spec.ts b/packages/angular_devkit/core/src/logger/logger_spec.ts index 50439ca9a452..1060d3f5b247 100644 --- a/packages/angular_devkit/core/src/logger/logger_spec.ts +++ b/packages/angular_devkit/core/src/logger/logger_spec.ts @@ -1,28 +1,30 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -// tslint:disable:no-any -import { toArray } from 'rxjs/operators'; -import { JsonValue } from '../json/interface'; -import { Logger } from './logger'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { lastValueFrom, toArray } from 'rxjs'; +import { JsonValue } from '../json/utils'; +import { Logger } from './logger'; describe('Logger', () => { it('works', (done: DoneFn) => { const logger = new Logger('test'); - logger.pipe(toArray()) - .toPromise() + lastValueFrom(logger.pipe(toArray())) .then((observed: JsonValue[]) => { expect(observed).toEqual([ - jasmine.objectContaining({ message: 'hello', level: 'debug', name: 'test' }) as any, - jasmine.objectContaining({ message: 'world', level: 'info', name: 'test' }) as any, + jasmine.objectContaining({ message: 'hello', level: 'debug', name: 'test' }), + jasmine.objectContaining({ message: 'world', level: 'info', name: 'test' }), ]); }) - .then(() => done(), err => done.fail(err)); + .then( + () => done(), + (err) => done.fail(err), + ); logger.debug('hello'); logger.info('world'); @@ -32,8 +34,7 @@ describe('Logger', () => { it('works with children', (done: DoneFn) => { const logger = new Logger('test'); let hasCompleted = false; - logger.pipe(toArray()) - .toPromise() + lastValueFrom(logger.pipe(toArray())) .then((observed: JsonValue[]) => { expect(observed).toEqual([ jasmine.objectContaining({ message: 'hello', level: 'debug', name: 'child' }) as any, @@ -41,10 +42,13 @@ describe('Logger', () => { ]); expect(hasCompleted).toBe(true); }) - .then(() => done(), err => done.fail(err)); + .then( + () => done(), + (err) => done.fail(err), + ); const childLogger = new Logger('child', logger); - childLogger.subscribe(undefined, undefined, () => hasCompleted = true); + childLogger.subscribe(undefined, undefined, () => (hasCompleted = true)); childLogger.debug('hello'); childLogger.info('world'); logger.complete(); @@ -54,14 +58,16 @@ describe('Logger', () => { const logger = new Logger('test'); logger.debug('woah'); - logger.pipe(toArray()) - .toPromise() + lastValueFrom(logger.pipe(toArray())) .then((observed: JsonValue[]) => { expect(observed).toEqual([ jasmine.objectContaining({ message: 'hello', level: 'debug', name: 'test' }) as any, ]); }) - .then(() => done(), err => done.fail(err)); + .then( + () => done(), + (err) => done.fail(err), + ); logger.debug('hello'); logger.complete(); diff --git a/packages/angular_devkit/core/src/logger/null-logger.ts b/packages/angular_devkit/core/src/logger/null-logger.ts index 4a687840a1c3..f5f40dfb0f7b 100644 --- a/packages/angular_devkit/core/src/logger/null-logger.ts +++ b/packages/angular_devkit/core/src/logger/null-logger.ts @@ -1,21 +1,21 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { EMPTY } from 'rxjs'; import { Logger, LoggerApi } from './logger'; - export class NullLogger extends Logger { constructor(parent: Logger | null = null) { super('', parent); this._observable = EMPTY; } - asApi(): LoggerApi { + override asApi(): LoggerApi { return { createChild: () => new NullLogger(this), log() {}, diff --git a/packages/angular_devkit/core/src/logger/null-logger_spec.ts b/packages/angular_devkit/core/src/logger/null-logger_spec.ts index 52f1936e48f9..3d99cf674da9 100644 --- a/packages/angular_devkit/core/src/logger/null-logger_spec.ts +++ b/packages/angular_devkit/core/src/logger/null-logger_spec.ts @@ -1,24 +1,26 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { toArray } from 'rxjs/operators'; + +import { lastValueFrom, toArray } from 'rxjs'; import { LogEntry, Logger } from './logger'; import { NullLogger } from './null-logger'; - describe('NullLogger', () => { it('works', (done: DoneFn) => { const logger = new NullLogger(); - logger.pipe(toArray()) - .toPromise() + lastValueFrom(logger.pipe(toArray())) .then((observed: LogEntry[]) => { expect(observed).toEqual([]); }) - .then(() => done(), err => done.fail(err)); + .then( + () => done(), + (err) => done.fail(err), + ); logger.debug('hello'); logger.info('world'); @@ -27,12 +29,14 @@ describe('NullLogger', () => { it('nullifies children', (done: DoneFn) => { const logger = new Logger('test'); - logger.pipe(toArray()) - .toPromise() + lastValueFrom(logger.pipe(toArray())) .then((observed: LogEntry[]) => { expect(observed).toEqual([]); }) - .then(() => done(), err => done.fail(err)); + .then( + () => done(), + (err) => done.fail(err), + ); const nullLogger = new NullLogger(logger); const child = new Logger('test', nullLogger); diff --git a/packages/angular_devkit/core/src/logger/transform-logger.ts b/packages/angular_devkit/core/src/logger/transform-logger.ts index 4d024fd2ffb2..de4c1ba449cb 100644 --- a/packages/angular_devkit/core/src/logger/transform-logger.ts +++ b/packages/angular_devkit/core/src/logger/transform-logger.ts @@ -1,18 +1,20 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { Observable } from 'rxjs'; import { LogEntry, Logger } from './logger'; - export class TransformLogger extends Logger { - constructor(name: string, - transform: (stream: Observable) => Observable, - parent: Logger | null = null) { + constructor( + name: string, + transform: (stream: Observable) => Observable, + parent: Logger | null = null, + ) { super(name, parent); this._observable = transform(this._observable); } diff --git a/packages/angular_devkit/core/src/logger/transform-logger_spec.ts b/packages/angular_devkit/core/src/logger/transform-logger_spec.ts index d99e2f87269c..2d8b04be7f7b 100644 --- a/packages/angular_devkit/core/src/logger/transform-logger_spec.ts +++ b/packages/angular_devkit/core/src/logger/transform-logger_spec.ts @@ -1,31 +1,34 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -// tslint:disable:no-any -import { filter, map, toArray } from 'rxjs/operators'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { filter, lastValueFrom, map, toArray } from 'rxjs'; import { LogEntry } from './logger'; import { TransformLogger } from './transform-logger'; - describe('TransformLogger', () => { it('works', (done: DoneFn) => { - const logger = new TransformLogger('test', stream => { + const logger = new TransformLogger('test', (stream) => { return stream.pipe( - filter(entry => entry.message != 'hello'), - map(entry => (entry.message += '1', entry))); + filter((entry) => entry.message != 'hello'), + map((entry) => ((entry.message += '1'), entry)), + ); }); - logger.pipe(toArray()) - .toPromise() + lastValueFrom(logger.pipe(toArray())) .then((observed: LogEntry[]) => { expect(observed).toEqual([ jasmine.objectContaining({ message: 'world1', level: 'info', name: 'test' }) as any, ]); }) - .then(() => done(), err => done.fail(err)); + .then( + () => done(), + (err) => done.fail(err), + ); logger.debug('hello'); logger.info('world'); diff --git a/packages/angular_devkit/core/src/terminal/caps.ts b/packages/angular_devkit/core/src/terminal/caps.ts deleted file mode 100644 index 0de9af74b88d..000000000000 --- a/packages/angular_devkit/core/src/terminal/caps.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * @license - * 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 - */ -import ReadableStream = NodeJS.ReadableStream; -import WriteStream = NodeJS.WriteStream; -import Socket = NodeJS.Socket; - - -/** - * Node specific stuff. - */ -declare const process: { - env: { [name: string]: string }; - platform: string; - versions: { - node: string; - }; - - stdin: ReadableStream; - stdout: WriteStream; - stderr: WriteStream; -}; -declare const os: { - release: () => string; -}; - - -const _env = (typeof process == 'object' && process.env) || {}; -const _platform = (typeof process == 'object' && process.platform) || ''; -const _versions = (typeof process == 'object' && process.versions) || { node: '' }; -const _os = (typeof os == 'object' && os) || { release: () => '' }; - -const streamMap = new WeakMap<{}, StreamCapabilities>(); - - -export interface StreamCapabilities { - readable: boolean; - writable: boolean; - - /** - * Supports text. This should be true for any streams. - */ - text: boolean; - - /** - * Supports colors (16 colors). - */ - colors: boolean; - - /** - * Supports 256 colors. - */ - color256: boolean; - - /** - * Supports 16 millions (3x8-bit channels) colors. - */ - color16m: boolean; - - /** - * Height of the terminal. If the stream is not tied to a terminal, will be null. - */ - rows: number | null; - - /** - * Width of the terminal. If the stream is not tied to a terminal, will be null. - */ - columns: number | null; -} - - -const ciVars = ['TRAVIS', 'CIRCLECI', 'APPVEYOR', 'GITLAB_CI']; - -function _getColorLevel(stream: Socket): 0 | 1 | 2 | 3 { - if ('FORCE_COLOR' in _env) { - if (_env.FORCE_COLOR === '1') { - return 3; - } else if (_env.FORCE_COLOR === '0') { - return 0; - } - } - - if (stream && !stream.isTTY && !_env.MSYSTEM) { - return 0; - } - - if (_platform.startsWith('win32') && !_env.MSYSTEM) { - // Node.js 7.5.0 is the first version of Node.js to include a patch to - // libuv that enables 256 color output on Windows. Anything earlier and it - // won't work. However, here we target Node.js 8 at minimum as it is an LTS - // release, and Node.js 7 is not. Windows 10 build 10586 is the first Windows - // release that supports 256 colors. - const osRelease = _os.release().split('.'); - if (Number(_versions.node.split('.')[0]) >= 8 - && Number(osRelease[0]) >= 10 - && Number(osRelease[2]) >= 10586) { - return 2; - } - - return 1; - } - - if ('CI' in _env) { - if (ciVars.some(sign => sign in _env) || _env.CI_NAME === 'codeship') { - return 1; - } - - return 0; - } - - if ('TEAMCITY_VERSION' in _env) { - return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(_env.TEAMCITY_VERSION) ? 1 : 0; - } - - if ('TERM_PROGRAM' in _env) { - const version = parseInt((_env.TERM_PROGRAM_VERSION || '').split('.')[0], 10); - - switch (_env.TERM_PROGRAM) { - case 'iTerm.app': - return version >= 3 ? 3 : 2; - case 'Hyper': - return 3; - case 'Apple_Terminal': - return 2; - - // No default - } - } - - if (/-256(color)?$/i.test(_env.TERM)) { - return 2; - } - - if (/^screen|^xterm|^vt100|^rxvt|color|ansi|cygwin|linux/i.test(_env.TERM)) { - return 1; - } - - if ('COLORTERM' in _env) { - return 1; - } - - if (_env.TERM === 'dumb') { - return 0; - } - - return 0; -} - - -function _getRows() { - return process.stdout.rows || null; -} -function _getColumns() { - return process.stdout.columns || null; -} - - -function _createCapabilities(stream: Socket, isTerminalStream: boolean): StreamCapabilities { - const level = _getColorLevel(stream); - - return { - readable: stream.readable, - writable: stream.writable, - text: true, - - colors: level > 0, - color256: level > 1, - color16m: level > 2, - - rows: isTerminalStream ? _getRows() : null, - columns: isTerminalStream ? _getColumns() : null, - }; -} - - -export function getCapabilities( - stream: Socket, - isTerminalStream = !!stream.isTTY, -): StreamCapabilities { - let maybeCaps = streamMap.get(stream); - if (!maybeCaps) { - maybeCaps = _createCapabilities(stream, isTerminalStream); - streamMap.set(stream, maybeCaps); - } - - return maybeCaps; -} - - -export const stdin = getCapabilities(process.stdin as Socket); -export const stdout = getCapabilities(process.stdout); -export const stderr = getCapabilities(process.stderr); diff --git a/packages/angular_devkit/core/src/terminal/colors.ts b/packages/angular_devkit/core/src/terminal/colors.ts deleted file mode 100644 index 6f509446efd3..000000000000 --- a/packages/angular_devkit/core/src/terminal/colors.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @license - * 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 - */ -import { mapObject } from '../utils/object'; - -const kColors = { - modifiers: { - reset: [0, 0], - bold: [1, 22], // 21 isn't widely supported and 22 does the same thing - dim: [2, 22], - italic: [3, 23], - underline: [4, 24], - inverse: [7, 27], - hidden: [8, 28], - strikethrough: [9, 29], - }, - colors: { - black: [30, 39], - red: [31, 39], - green: [32, 39], - yellow: [33, 39], - blue: [34, 39], - magenta: [35, 39], - cyan: [36, 39], - white: [37, 39], - gray: [90, 39], - }, - bgColors: { - bgBlack: [40, 49], - bgRed: [41, 49], - bgGreen: [42, 49], - bgYellow: [43, 49], - bgBlue: [44, 49], - bgMagenta: [45, 49], - bgCyan: [46, 49], - bgWhite: [47, 49], - }, -}; -const kColorFunctions = mapObject(kColors, (_, v) => { - return mapObject(v, (_, vv) => (x: string) => `\u001b[${vv[0]}m${x}\u001b[${vv[1]}m`); -}); - - -export const reset = kColorFunctions.modifiers.reset; -export const bold = kColorFunctions.modifiers.bold; -export const dim = kColorFunctions.modifiers.dim; -export const italic = kColorFunctions.modifiers.italic; -export const underline = kColorFunctions.modifiers.underline; -export const inverse = kColorFunctions.modifiers.inverse; -export const hidden = kColorFunctions.modifiers.hidden; -export const strikethrough = kColorFunctions.modifiers.strikethrough; - -export const black = kColorFunctions.colors.black; -export const red = kColorFunctions.colors.red; -export const green = kColorFunctions.colors.green; -export const yellow = kColorFunctions.colors.yellow; -export const blue = kColorFunctions.colors.blue; -export const magenta = kColorFunctions.colors.magenta; -export const cyan = kColorFunctions.colors.cyan; -export const white = kColorFunctions.colors.white; -export const grey = kColorFunctions.colors.gray; -export const gray = kColorFunctions.colors.gray; - -export const bgBlack = kColorFunctions.bgColors.bgBlack; -export const bgRed = kColorFunctions.bgColors.bgRed; -export const bgGreen = kColorFunctions.bgColors.bgGreen; -export const bgYellow = kColorFunctions.bgColors.bgYellow; -export const bgBlue = kColorFunctions.bgColors.bgBlue; -export const bgMagenta = kColorFunctions.bgColors.bgMagenta; -export const bgCyan = kColorFunctions.bgColors.bgCyan; -export const bgWhite = kColorFunctions.bgColors.bgWhite; diff --git a/packages/angular_devkit/core/src/terminal/index.ts b/packages/angular_devkit/core/src/terminal/index.ts deleted file mode 100644 index e4bcae665520..000000000000 --- a/packages/angular_devkit/core/src/terminal/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * @license - * 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 - */ -export * from './text'; - -import * as colors from './colors'; -export { colors }; diff --git a/packages/angular_devkit/core/src/terminal/text.ts b/packages/angular_devkit/core/src/terminal/text.ts deleted file mode 100644 index bf4f9e7ebae2..000000000000 --- a/packages/angular_devkit/core/src/terminal/text.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * @license - * 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 - */ -import * as caps from './caps'; -import * as ansi from './colors'; - - -export const reset = caps.stdout.colors ? ansi.reset : (x: string) => x; -export const bold = caps.stdout.colors ? ansi.bold : (x: string) => x; -export const dim = caps.stdout.colors ? ansi.dim : (x: string) => x; -export const italic = caps.stdout.colors ? ansi.italic : (x: string) => x; -export const underline = caps.stdout.colors ? ansi.underline : (x: string) => x; -export const inverse = caps.stdout.colors ? ansi.inverse : (x: string) => x; -export const hidden = caps.stdout.colors ? ansi.hidden : (x: string) => x; -export const strikethrough = caps.stdout.colors ? ansi.strikethrough : (x: string) => x; - -export const black = caps.stdout.colors ? ansi.black : (x: string) => x; -export const red = caps.stdout.colors ? ansi.red : (x: string) => x; -export const green = caps.stdout.colors ? ansi.green : (x: string) => x; -export const yellow = caps.stdout.colors ? ansi.yellow : (x: string) => x; -export const blue = caps.stdout.colors ? ansi.blue : (x: string) => x; -export const magenta = caps.stdout.colors ? ansi.magenta : (x: string) => x; -export const cyan = caps.stdout.colors ? ansi.cyan : (x: string) => x; -export const white = caps.stdout.colors ? ansi.white : (x: string) => x; -export const grey = caps.stdout.colors ? ansi.gray : (x: string) => x; -export const gray = caps.stdout.colors ? ansi.gray : (x: string) => x; - -export const bgBlack = caps.stdout.colors ? ansi.bgBlack : (x: string) => x; -export const bgRed = caps.stdout.colors ? ansi.bgRed : (x: string) => x; -export const bgGreen = caps.stdout.colors ? ansi.bgGreen : (x: string) => x; -export const bgYellow = caps.stdout.colors ? ansi.bgYellow : (x: string) => x; -export const bgBlue = caps.stdout.colors ? ansi.bgBlue : (x: string) => x; -export const bgMagenta = caps.stdout.colors ? ansi.bgMagenta : (x: string) => x; -export const bgCyan = caps.stdout.colors ? ansi.bgCyan : (x: string) => x; -export const bgWhite = caps.stdout.colors ? ansi.bgWhite : (x: string) => x; diff --git a/packages/angular_devkit/core/src/utils/array.ts b/packages/angular_devkit/core/src/utils/array.ts deleted file mode 100644 index e1969c175723..000000000000 --- a/packages/angular_devkit/core/src/utils/array.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * @license - * 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 - */ - -export function clean(array: Array): Array { - return array.filter(x => x !== undefined) as Array; -} diff --git a/packages/angular_devkit/core/src/utils/index.ts b/packages/angular_devkit/core/src/utils/index.ts index 10b185560299..cf9570c91858 100644 --- a/packages/angular_devkit/core/src/utils/index.ts +++ b/packages/angular_devkit/core/src/utils/index.ts @@ -1,14 +1,14 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 tags from './literals'; import * as strings from './strings'; -export * from './array'; export * from './object'; export * from './template'; export * from './partially-ordered-set'; diff --git a/packages/angular_devkit/core/src/utils/lang.ts b/packages/angular_devkit/core/src/utils/lang.ts index 1fc0b09cbe4d..ca59b945cbc8 100644 --- a/packages/angular_devkit/core/src/utils/lang.ts +++ b/packages/angular_devkit/core/src/utils/lang.ts @@ -1,35 +1,21 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ + // Borrowed from @angular/core -import { Observable } from 'rxjs'; /** * Determine if the argument is shaped like a Promise + * + * @deprecated Use `typeof obj?.then === 'function'` instead. */ -// tslint:disable-next-line:no-any +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function isPromise(obj: any): obj is Promise { // allow any Promise/A+ compliant thenable. // It's up to the caller to ensure that obj.then conforms to the spec return !!obj && typeof obj.then === 'function'; } - -/** - * Determine if the argument is an Observable - */ -// tslint:disable-next-line:no-any -export function isObservable(obj: any | Observable): obj is Observable { - if (!obj || typeof obj !== 'object') { - return false; - } - - if (Symbol.observable && Symbol.observable in obj) { - return true; - } - - return typeof obj.subscribe === 'function'; -} diff --git a/packages/angular_devkit/core/src/utils/literals.ts b/packages/angular_devkit/core/src/utils/literals.ts index bfa16c201c98..5dc8f2a1dfeb 100644 --- a/packages/angular_devkit/core/src/utils/literals.ts +++ b/packages/angular_devkit/core/src/utils/literals.ts @@ -1,18 +1,18 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ + export interface TemplateTag { // Any is the only way here. - // tslint:disable-next-line:no-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any (template: TemplateStringsArray, ...substitutions: any[]): R; } - -// tslint:disable-next-line:no-any +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function oneLine(strings: TemplateStringsArray, ...values: any[]) { const endResult = String.raw(strings, ...values); @@ -30,8 +30,7 @@ export function indentBy(indentations: number): TemplateTag { }; } - -// tslint:disable-next-line:no-any +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function stripIndent(strings: TemplateStringsArray, ...values: any[]) { const endResult = String.raw(strings, ...values); @@ -43,29 +42,30 @@ export function stripIndent(strings: TemplateStringsArray, ...values: any[]) { return endResult; } - const indent = Math.min(...match.map(el => el.length)); + const indent = Math.min(...match.map((el) => el.length)); const regexp = new RegExp('^[ \\t]{' + indent + '}', 'gm'); return (indent > 0 ? endResult.replace(regexp, '') : endResult).trim(); } - -// tslint:disable-next-line:no-any +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function stripIndents(strings: TemplateStringsArray, ...values: any[]) { return String.raw(strings, ...values) .split('\n') - .map(line => line.trim()) + .map((line) => line.trim()) .join('\n') .trim(); } -// tslint:disable-next-line:no-any +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function trimNewlines(strings: TemplateStringsArray, ...values: any[]) { const endResult = String.raw(strings, ...values); - return endResult - // Remove the newline at the start. - .replace(/^(?:\r?\n)+/, '') - // Remove the newline at the end and following whitespace. - .replace(/(?:\r?\n(?:\s*))$/, ''); + return ( + endResult + // Remove the newline at the start. + .replace(/^(?:\r?\n)+/, '') + // Remove the newline at the end and following whitespace. + .replace(/(?:\r?\n(?:\s*))$/, '') + ); } diff --git a/packages/angular_devkit/core/src/utils/literals_spec.ts b/packages/angular_devkit/core/src/utils/literals_spec.ts index 0883eb685535..a880eac7547c 100644 --- a/packages/angular_devkit/core/src/utils/literals_spec.ts +++ b/packages/angular_devkit/core/src/utils/literals_spec.ts @@ -1,10 +1,11 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { oneLine, stripIndent, stripIndents, trimNewlines } from './literals'; describe('literals', () => { diff --git a/packages/angular_devkit/core/src/utils/object.ts b/packages/angular_devkit/core/src/utils/object.ts index 3d5d9edf2475..9e7e5b78d3ad 100644 --- a/packages/angular_devkit/core/src/utils/object.ts +++ b/packages/angular_devkit/core/src/utils/object.ts @@ -1,42 +1,42 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -export function mapObject(obj: {[k: string]: T}, - mapper: (k: string, v: T) => V): {[k: string]: V} { - return Object.keys(obj).reduce((acc: {[k: string]: V}, k: string) => { - acc[k] = mapper(k, obj[k]); - - return acc; - }, {}); -} - - const copySymbol = Symbol(); -// tslint:disable-next-line:no-any -export function deepCopy (value: T): T { +/** + * @deprecated Use `structuredClone` instead. + */ +export function deepCopy(value: T): T { if (Array.isArray(value)) { - return value.map((o: T) => deepCopy(o)); + return value.map((o) => deepCopy(o)) as unknown as T; } else if (value && typeof value === 'object') { - if (value[copySymbol]) { + const valueCasted = value as unknown as { + [copySymbol]?: T; + toJSON?: () => string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; + }; + + if (valueCasted[copySymbol]) { // This is a circular dependency. Just return the cloned value. - return value[copySymbol]; + return valueCasted[copySymbol] as T; } - if (value['toJSON']) { - return JSON.parse((value['toJSON'] as () => string)()); + + if (valueCasted['toJSON']) { + return JSON.parse(valueCasted['toJSON']()) as T; } - const copy = new (Object.getPrototypeOf(value).constructor)(); - value[copySymbol] = copy; - for (const key of Object.getOwnPropertyNames(value)) { - copy[key] = deepCopy(value[key]); + const copy = Object.create(Object.getPrototypeOf(valueCasted)); + valueCasted[copySymbol] = copy; + for (const key of Object.getOwnPropertyNames(valueCasted)) { + copy[key] = deepCopy(valueCasted[key]); } - value[copySymbol] = undefined; + delete valueCasted[copySymbol]; return copy; } else { diff --git a/packages/angular_devkit/core/src/utils/object_spec.ts b/packages/angular_devkit/core/src/utils/object_spec.ts index 635c783c5f1d..da09820f2053 100644 --- a/packages/angular_devkit/core/src/utils/object_spec.ts +++ b/packages/angular_devkit/core/src/utils/object_spec.ts @@ -1,11 +1,12 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -// tslint:disable:no-any + +/* eslint-disable @typescript-eslint/no-explicit-any */ import { deepCopy } from './object'; describe('object', () => { @@ -32,7 +33,11 @@ describe('object', () => { it('works with simple classes', () => { class Data { - constructor(private _x = 1, protected _y = 2, public _z = 3) {} + constructor( + private _x = 1, + protected _y = 2, + public _z = 3, + ) {} } const data = new Data(); expect(deepCopy(data)).toEqual(data); @@ -50,5 +55,11 @@ describe('object', () => { expect(result.b).not.toBe(data1); expect(result.b).toBe(result.b.circular.b); }); + + it('works with null prototype', () => { + const data = Object.create(null); + data['a'] = 1; + expect(deepCopy(data)).toEqual(data); + }); }); }); diff --git a/packages/angular_devkit/core/src/utils/partially-ordered-set.ts b/packages/angular_devkit/core/src/utils/partially-ordered-set.ts index 2127f521eef2..258b6138f66f 100644 --- a/packages/angular_devkit/core/src/utils/partially-ordered-set.ts +++ b/packages/angular_devkit/core/src/utils/partially-ordered-set.ts @@ -1,42 +1,50 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { BaseException } from '../exception'; export class DependencyNotFoundException extends BaseException { - constructor() { super('One of the dependencies is not part of the set.'); } + constructor() { + super('One of the dependencies is not part of the set.'); + } } export class CircularDependencyFoundException extends BaseException { - constructor() { super('Circular dependencies found.'); } + constructor() { + super('Circular dependencies found.'); + } } -export class PartiallyOrderedSet implements Set { +/** + * @deprecated Use standard arrays and ensure correct insertion order instead. + */ +export class PartiallyOrderedSet { private _items = new Map>(); - protected _checkCircularDependencies(item: T, deps: Set) { + protected _checkCircularDependencies(item: T, deps: Set): void { if (deps.has(item)) { throw new CircularDependencyFoundException(); } - deps.forEach(dep => this._checkCircularDependencies(item, this._items.get(dep) || new Set())); + deps.forEach((dep) => this._checkCircularDependencies(item, this._items.get(dep) || new Set())); } - clear() { + clear(): void { this._items.clear(); } - has(item: T) { + has(item: T): boolean { return this._items.has(item); } - get size() { + get size(): number { return this._items.size; } forEach( callbackfn: (value: T, value2: T, set: PartiallyOrderedSet) => void, - thisArg?: any, // tslint:disable-line:no-any + thisArg?: any, // eslint-disable-line @typescript-eslint/no-explicit-any ): void { for (const x of this) { callbackfn.call(thisArg, x, x, this); @@ -66,8 +74,7 @@ export class PartiallyOrderedSet implements Set { return this[Symbol.iterator](); } - - add(item: T, deps: (Set | T[]) = new Set()) { + add(item: T, deps: Set | T[] = new Set()): this { if (Array.isArray(deps)) { deps = new Set(deps); } @@ -77,23 +84,7 @@ export class PartiallyOrderedSet implements Set { const itemDeps = this._items.get(item) || new Set(); // If the dependency list is equal, just return, otherwise remove and keep going. - let equal = true; - for (const dep of deps) { - if (!itemDeps.has(dep)) { - equal = false; - break; - } - } - if (equal) { - for (const dep of itemDeps) { - if (!deps.has(dep)) { - equal = false; - break; - } - } - } - - if (equal) { + if (deps.size === itemDeps.size && deps.isSubsetOf(itemDeps)) { return this; } else { this._items.delete(item); @@ -101,10 +92,8 @@ export class PartiallyOrderedSet implements Set { } // Verify all dependencies are part of the Set. - for (const dep of deps) { - if (!this._items.has(dep)) { - throw new DependencyNotFoundException(); - } + if (!deps.isSubsetOf(this._items)) { + throw new DependencyNotFoundException(); } // Verify there's no dependency cycle. @@ -115,18 +104,18 @@ export class PartiallyOrderedSet implements Set { return this; } - delete(item: T) { + delete(item: T): boolean { if (!this._items.has(item)) { return false; } // Remove it from all dependencies if force == true. - this._items.forEach(value => value.delete(item)); + this._items.forEach((value) => value.delete(item)); return this._items.delete(item); } - *[Symbol.iterator]() { + *[Symbol.iterator](): IterableIterator { const copy: Map> = new Map(this._items); for (const [key, value] of copy.entries()) { @@ -134,7 +123,7 @@ export class PartiallyOrderedSet implements Set { } while (copy.size > 0) { - const run = []; + const run: T[] = []; // Take the first item without dependencies. for (const [item, deps] of copy.entries()) { if (deps.size == 0) { @@ -143,7 +132,7 @@ export class PartiallyOrderedSet implements Set { } for (const item of run) { - copy.forEach(s => s.delete(item)); + copy.forEach((s) => s.delete(item)); copy.delete(item); yield item; } @@ -153,9 +142,11 @@ export class PartiallyOrderedSet implements Set { throw new CircularDependencyFoundException(); } } + + return undefined; } - get [Symbol.toStringTag](): 'Set' { - return 'Set'; + get [Symbol.toStringTag](): 'PartiallyOrderedSet' { + return 'PartiallyOrderedSet'; } } diff --git a/packages/angular_devkit/core/src/utils/partially-ordered-set_spec.ts b/packages/angular_devkit/core/src/utils/partially-ordered-set_spec.ts index 31aa667cb54e..d9d49457d110 100644 --- a/packages/angular_devkit/core/src/utils/partially-ordered-set_spec.ts +++ b/packages/angular_devkit/core/src/utils/partially-ordered-set_spec.ts @@ -1,10 +1,11 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { PartiallyOrderedSet } from './partially-ordered-set'; describe('PartiallyOrderedSet', () => { diff --git a/packages/angular_devkit/core/src/utils/priority-queue.ts b/packages/angular_devkit/core/src/utils/priority-queue.ts index 9d5008487d47..e2baae889d1f 100644 --- a/packages/angular_devkit/core/src/utils/priority-queue.ts +++ b/packages/angular_devkit/core/src/utils/priority-queue.ts @@ -1,9 +1,9 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ /** Naive priority queue; not intended for large datasets */ @@ -12,12 +12,12 @@ export class PriorityQueue { constructor(private _comparator: (x: T, y: T) => number) {} - clear() { + clear(): void { this._items = new Array(); } - push(item: T) { - const index = this._items.findIndex(existing => this._comparator(item, existing) <= 0); + push(item: T): void { + const index = this._items.findIndex((existing) => this._comparator(item, existing) <= 0); if (index === -1) { this._items.push(item); diff --git a/packages/angular_devkit/core/src/utils/priority-queue_spec.ts b/packages/angular_devkit/core/src/utils/priority-queue_spec.ts index ef850273de4e..454e7ddff936 100644 --- a/packages/angular_devkit/core/src/utils/priority-queue_spec.ts +++ b/packages/angular_devkit/core/src/utils/priority-queue_spec.ts @@ -1,12 +1,12 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { PriorityQueue } from './priority-queue'; +import { PriorityQueue } from './priority-queue'; describe('PriorityQueue', () => { it('adds an item', () => { diff --git a/packages/angular_devkit/core/src/utils/strings.ts b/packages/angular_devkit/core/src/utils/strings.ts index ff3c0ce7cec3..2dbc40970941 100644 --- a/packages/angular_devkit/core/src/utils/strings.ts +++ b/packages/angular_devkit/core/src/utils/strings.ts @@ -1,15 +1,16 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -const STRING_DASHERIZE_REGEXP = (/[ _]/g); -const STRING_DECAMELIZE_REGEXP = (/([a-z\d])([A-Z])/g); -const STRING_CAMELIZE_REGEXP = (/(-|_|\.|\s)+(.)?/g); -const STRING_UNDERSCORE_REGEXP_1 = (/([a-z\d])([A-Z]+)/g); -const STRING_UNDERSCORE_REGEXP_2 = (/-|\s+/g); + +const STRING_DASHERIZE_REGEXP = /[ _]/g; +const STRING_DECAMELIZE_REGEXP = /([a-z\d])([A-Z])/g; +const STRING_CAMELIZE_REGEXP = /(-|_|\.|\s)+(.)?/g; +const STRING_UNDERSCORE_REGEXP_1 = /([a-z\d])([A-Z]+)/g; +const STRING_UNDERSCORE_REGEXP_2 = /-|\s+/g; /** * Converts a camelized string into all lower case separated by underscores. @@ -73,23 +74,27 @@ export function camelize(str: string): string { /** Returns the UpperCamelCase form of a string. + @example ```javascript 'innerHTML'.classify(); // 'InnerHTML' 'action_name'.classify(); // 'ActionName' 'css-class-name'.classify(); // 'CssClassName' 'my favorite items'.classify(); // 'MyFavoriteItems' + 'app.component'.classify(); // 'AppComponent' ``` - @method classify @param {String} str the string to classify @return {String} the classified string */ export function classify(str: string): string { - return str.split('.').map(part => capitalize(camelize(part))).join('.'); + return str + .split('.') + .map((part) => capitalize(camelize(part))) + .join(''); } /** - More general than decamelize. Returns the lower\_case\_and\_underscored + More general than decamelize. Returns the lower_case_and_underscored form of a string. ```javascript @@ -125,7 +130,7 @@ export function underscore(str: string): string { @return {String} The capitalized string. */ export function capitalize(str: string): string { - return str.charAt(0).toUpperCase() + str.substr(1); + return str.charAt(0).toUpperCase() + str.slice(1); } /** @@ -147,7 +152,7 @@ export function levenshtein(a: string, b: string): number { return a.length; } - const matrix = []; + const matrix: number[][] = []; // increment along the first column of each row for (let i = 0; i <= b.length; i++) { diff --git a/packages/angular_devkit/core/src/utils/template.ts b/packages/angular_devkit/core/src/utils/template.ts index 1ed86bd81098..f28bbf7ad417 100644 --- a/packages/angular_devkit/core/src/utils/template.ts +++ b/packages/angular_devkit/core/src/utils/template.ts @@ -1,10 +1,11 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { Position, SourceNode } from 'source-map'; // Matches <%= expr %>. This does not support structural JavaScript (for/if/...). @@ -19,7 +20,7 @@ const kEscapeRe = /<%-([\s\S]+?)%>/g; const kEvaluateRe = /<%([\s\S]+?)%>/g; /** Used to map characters to HTML entities. */ -const kHtmlEscapes: {[char: string]: string} = { +const kHtmlEscapes: { [char: string]: string } = { '&': '&', '<': '<', '>': '>', @@ -40,7 +41,6 @@ export interface TemplateOptions { fileName?: string; } - function _positionFor(content: string, offset: number): Position { let line = 1; let column = 0; @@ -117,21 +117,22 @@ export interface TemplateAstInterpolate extends TemplateAstBase { expression: string; } -export type TemplateAstNode = TemplateAstContent - | TemplateAstEvaluate - | TemplateAstComment - | TemplateAstEscape - | TemplateAstInterpolate; +export type TemplateAstNode = + | TemplateAstContent + | TemplateAstEvaluate + | TemplateAstComment + | TemplateAstEscape + | TemplateAstInterpolate; /** * Given a source text (and a fileName), returns a TemplateAst. */ export function templateParser(sourceText: string, fileName: string): TemplateAst { - const children = []; + const children: TemplateAstNode[] = []; // Compile the regexp to match each delimiter. const reExpressions = [kEscapeRe, kCommentRe, kInterpolateRe, kEvaluateRe]; - const reDelimiters = RegExp(reExpressions.map(x => x.source).join('|') + '|$', 'g'); + const reDelimiters = RegExp(reExpressions.map((x) => x.source).join('|') + '|$', 'g'); const parsed = sourceText.split(reDelimiters); let offset = 0; @@ -193,7 +194,7 @@ export function templateParser(sourceText: string, fileName: string): TemplateAs */ function templateFast(ast: TemplateAst, options?: TemplateOptions): string { const module = options && options.module ? 'module.exports.default =' : ''; - const reHtmlEscape = reUnescapedHtml.source.replace(/[']/g, '\\\\\\\''); + const reHtmlEscape = reUnescapedHtml.source.replace(/[']/g, "\\\\\\'"); return ` return ${module} function(obj) { @@ -207,7 +208,8 @@ function templateFast(ast: TemplateAst, options?: TemplateOptions): string { return s ? s.replace(__escapesre, function(key) { return __escapes[key]; }) : ''; }; with (obj) { - ${ast.children.map(node => { + ${ast.children + .map((node) => { switch (node.kind) { case 'content': return `__p += ${JSON.stringify(node.content)};`; @@ -218,8 +220,8 @@ function templateFast(ast: TemplateAst, options?: TemplateOptions): string { case 'evaluate': return node.expression; } - }).join('\n') - } + }) + .join('\n')} } return __p; @@ -233,10 +235,10 @@ function templateFast(ast: TemplateAst, options?: TemplateOptions): string { function templateWithSourceMap(ast: TemplateAst, options?: TemplateOptions): string { const sourceUrl = ast.fileName; const module = options && options.module ? 'module.exports.default =' : ''; - const reHtmlEscape = reUnescapedHtml.source.replace(/[']/g, '\\\\\\\''); + const reHtmlEscape = reUnescapedHtml.source.replace(/[']/g, "\\\\\\'"); - const preamble = (new SourceNode(1, 0, sourceUrl, '')) - .add(new SourceNode(1, 0, sourceUrl, [ + const preamble = new SourceNode(1, 0, sourceUrl, '').add( + new SourceNode(1, 0, sourceUrl, [ `return ${module} function(obj) {\n`, ' obj || (obj = {});\n', ' let __t;\n', @@ -248,96 +250,93 @@ function templateWithSourceMap(ast: TemplateAst, options?: TemplateOptions): str ` return s ? s.replace(__escapesre, function(key) { return __escapes[key]; }) : '';`, ` };\n`, ` with (obj) {\n`, - ])); - - const end = ast.children.length - ? ast.children[ast.children.length - 1].end - : { line: 0, column: 0 }; - const nodes = ast.children.reduce((chunk, node) => { - let code: string | SourceNode | (SourceNode | string)[] = ''; - switch (node.kind) { - case 'content': - code = [ - new SourceNode(node.start.line, node.start.column, sourceUrl, '__p = __p'), - ...node.content.split('\n').map((line, i, arr) => { - return new SourceNode( - node.start.line + i, - i == 0 ? node.start.column : 0, - sourceUrl, - '\n + ' - + JSON.stringify(line + (i == arr.length - 1 ? '' : '\n')), - ); - }), - new SourceNode(node.end.line, node.end.column, sourceUrl, ';\n'), - ]; - break; - case 'interpolate': - code = [ - new SourceNode(node.start.line, node.start.column, sourceUrl, '__p += ((__t = '), - ...node.expression.split('\n').map((line, i, arr) => { - return new SourceNode( - node.start.line + i, - i == 0 ? node.start.column : 0, - sourceUrl, - line + ((i == arr.length - 1) ? '' : '\n'), - ); - }), - new SourceNode(node.end.line, node.end.column, sourceUrl, ') == null ? "" : __t);\n'), - ]; - break; - case 'escape': - code = [ - new SourceNode(node.start.line, node.start.column, sourceUrl, '__p += __e('), - ...node.expression.split('\n').map((line, i, arr) => { - return new SourceNode( - node.start.line + i, - i == 0 ? node.start.column : 0, - sourceUrl, - line + ((i == arr.length - 1) ? '' : '\n'), - ); - }), - new SourceNode(node.end.line, node.end.column, sourceUrl, ');\n'), - ]; - break; - case 'evaluate': - code = [ - ...node.expression.split('\n').map((line, i, arr) => { - return new SourceNode( - node.start.line + i, - i == 0 ? node.start.column : 0, - sourceUrl, - line + ((i == arr.length - 1) ? '' : '\n'), - ); - }), - new SourceNode(node.end.line, node.end.column, sourceUrl, '\n'), - ]; - break; - } + ]), + ); + + const end = ast.children.at(-1)?.end ?? { line: 0, column: 0 }; + const nodes = ast.children + .reduce((chunk, node) => { + let code: string | SourceNode | (SourceNode | string)[] = ''; + switch (node.kind) { + case 'content': + code = [ + new SourceNode(node.start.line, node.start.column, sourceUrl, '__p = __p'), + ...node.content.split('\n').map((line, i, arr) => { + return new SourceNode( + node.start.line + i, + i == 0 ? node.start.column : 0, + sourceUrl, + '\n + ' + JSON.stringify(line + (i == arr.length - 1 ? '' : '\n')), + ); + }), + new SourceNode(node.end.line, node.end.column, sourceUrl, ';\n'), + ]; + break; + case 'interpolate': + code = [ + new SourceNode(node.start.line, node.start.column, sourceUrl, '__p += ((__t = '), + ...node.expression.split('\n').map((line, i, arr) => { + return new SourceNode( + node.start.line + i, + i == 0 ? node.start.column : 0, + sourceUrl, + line + (i == arr.length - 1 ? '' : '\n'), + ); + }), + new SourceNode(node.end.line, node.end.column, sourceUrl, ') == null ? "" : __t);\n'), + ]; + break; + case 'escape': + code = [ + new SourceNode(node.start.line, node.start.column, sourceUrl, '__p += __e('), + ...node.expression.split('\n').map((line, i, arr) => { + return new SourceNode( + node.start.line + i, + i == 0 ? node.start.column : 0, + sourceUrl, + line + (i == arr.length - 1 ? '' : '\n'), + ); + }), + new SourceNode(node.end.line, node.end.column, sourceUrl, ');\n'), + ]; + break; + case 'evaluate': + code = [ + ...node.expression.split('\n').map((line, i, arr) => { + return new SourceNode( + node.start.line + i, + i == 0 ? node.start.column : 0, + sourceUrl, + line + (i == arr.length - 1 ? '' : '\n'), + ); + }), + new SourceNode(node.end.line, node.end.column, sourceUrl, '\n'), + ]; + break; + } - return chunk.add(new SourceNode(node.start.line, node.start.column, sourceUrl, code)); - }, preamble) - .add(new SourceNode(end.line, end.column, sourceUrl, [ - ' };\n', - '\n', - ' return __p;\n', - '}\n', - ])); + return chunk.add(new SourceNode(node.start.line, node.start.column, sourceUrl, code)); + }, preamble) + .add( + new SourceNode(end.line, end.column, sourceUrl, [' };\n', '\n', ' return __p;\n', '}\n']), + ); const code = nodes.toStringWithSourceMap({ file: sourceUrl, - sourceRoot: options && options.sourceRoot || '.', + sourceRoot: (options && options.sourceRoot) || '.', }); // Set the source content in the source map, otherwise the sourceUrl is not enough // to find the content. code.map.setSourceContent(sourceUrl, ast.content); - return code.code - + '\n//# sourceMappingURL=data:application/json;base64,' - + Buffer.from(code.map.toString()).toString('base64'); + return ( + code.code + + '\n//# sourceMappingURL=data:application/json;base64,' + + Buffer.from(code.map.toString()).toString('base64') + ); } - /** * An equivalent of EJS templates, which is based on John Resig's `tmpl` implementation * (http://ejohn.org/blog/javascript-micro-templating/) and Laura Doktorova's doT.js @@ -354,7 +353,7 @@ function templateWithSourceMap(ast: TemplateAst, options?: TemplateOptions): str * of the template with the input applied. */ export function template(content: string, options?: TemplateOptions): (input: T) => string { - const sourceUrl = options && options.sourceURL || 'ejs'; + const sourceUrl = (options && options.sourceURL) || 'ejs'; const ast = templateParser(content, sourceUrl); let source: string; @@ -369,9 +368,8 @@ export function template(content: string, options?: TemplateOptions): (input: // need to only use the source, not the function itself. Otherwise expect a module object to be // passed, and we use that one. const fn = Function('module', source); - const module = options && options.module - ? (options.module === true ? { exports: {} } : options.module) - : null; + const module = + options && options.module ? (options.module === true ? { exports: {} } : options.module) : null; const result = fn(module); // Provide the compiled function's source by its `toString` method or diff --git a/packages/angular_devkit/core/src/virtual-fs/host/alias.ts b/packages/angular_devkit/core/src/virtual-fs/host/alias.ts index 4df9c6b1fbe2..1b8c88e08b6b 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/alias.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/alias.ts @@ -1,14 +1,14 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { NormalizedRoot, Path, PathFragment, join, split } from '../path'; import { ResolverHost } from './resolver'; - /** * A Virtual Host that allow to alias some paths to other paths. * @@ -56,9 +56,9 @@ import { ResolverHost } from './resolver'; * .subscribe(x => expect(x).toBe(content2)); */ export class AliasHost extends ResolverHost { - protected _aliases = new Map(); + protected _aliases: Map = new Map(); - protected _resolve(path: Path) { + protected _resolve(path: Path): Path { let maybeAlias = this._aliases.get(path); const sp = split(path); const remaining: PathFragment[] = []; @@ -74,11 +74,13 @@ export class AliasHost extends ResolverHost maybeAlias = join(maybeAlias, ...remaining); } // Allow non-null-operator because we know sp.length > 0 (condition on while). - remaining.unshift(sp.pop() !); // tslint:disable-line:no-non-null-assertion + remaining.unshift(sp.pop()!); // eslint-disable-line @typescript-eslint/no-non-null-assertion } return maybeAlias || path; } - get aliases(): Map { return this._aliases; } + get aliases(): Map { + return this._aliases; + } } diff --git a/packages/angular_devkit/core/src/virtual-fs/host/alias_spec.ts b/packages/angular_devkit/core/src/virtual-fs/host/alias_spec.ts index ff852af9910e..66ae4b0137f4 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/alias_spec.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/alias_spec.ts @@ -1,59 +1,56 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 '..'; import { AliasHost } from './alias'; -import { stringToFileBuffer } from './buffer'; import { SimpleMemoryHost } from './memory'; describe('AliasHost', () => { it('works as in the example', () => { - const content = stringToFileBuffer('hello world'); + const content = new TextEncoder().encode('hello world').buffer; const host = new SimpleMemoryHost(); host.write(normalize('/some/file'), content).subscribe(); const aHost = new AliasHost(host); - aHost.read(normalize('/some/file')) - .subscribe(x => expect(x).toBe(content)); + aHost.read(normalize('/some/file')).subscribe((x) => expect(x).toBe(content)); aHost.aliases.set(normalize('/some/file'), normalize('/other/path')); // This file will not exist because /other/path does not exist. try { - aHost.read(normalize('/some/file')) - .subscribe(undefined, err => { - expect(err.message).toMatch(/does not exist/); - }); + aHost.read(normalize('/some/file')).subscribe(undefined, (err) => { + expect(err.message).toMatch(/does not exist/); + }); } catch { // Ignore it. RxJS <6 still throw errors when they happen synchronously. } }); it('works as in the example (2)', () => { - const content = stringToFileBuffer('hello world'); - const content2 = stringToFileBuffer('hello world 2'); + const content = new TextEncoder().encode('hello world').buffer; + const content2 = new TextEncoder().encode('hello world 2').buffer; const host = new SimpleMemoryHost(); host.write(normalize('/some/folder/file'), content).subscribe(); const aHost = new AliasHost(host); - aHost.read(normalize('/some/folder/file')) - .subscribe(x => expect(x).toBe(content)); + aHost.read(normalize('/some/folder/file')).subscribe((x) => expect(x).toBe(content)); aHost.aliases.set(normalize('/some'), normalize('/other')); // This file will not exist because /other/path does not exist. try { - aHost.read(normalize('/some/folder/file')) - .subscribe(undefined, err => expect(err.message).toMatch(/does not exist/)); + aHost + .read(normalize('/some/folder/file')) + .subscribe(undefined, (err) => expect(err.message).toMatch(/does not exist/)); } catch {} // Create the file with new content and verify that this has the new content. aHost.write(normalize('/other/folder/file'), content2).subscribe(); - aHost.read(normalize('/some/folder/file')) - .subscribe(x => expect(x).toBe(content2)); + aHost.read(normalize('/some/folder/file')).subscribe((x) => expect(x).toBe(content2)); }); }); diff --git a/packages/angular_devkit/core/src/virtual-fs/host/buffer.ts b/packages/angular_devkit/core/src/virtual-fs/host/buffer.ts index 318a7d8b321b..becaa3ecdffd 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/buffer.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/buffer.ts @@ -1,81 +1,27 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { TemplateTag } from '../../utils/literals'; -import { FileBuffer } from './interface'; - -declare const TextEncoder: { - new (encoding: string): { - encode(str: string): Uint8Array; - }; -}; -declare const TextDecoder: { - new(encoding: string): { - decode(bytes: Uint8Array): string; - }; -}; +import { FileBuffer } from './interface'; +/** + * @deprecated Use `new TextEncoder().encode(str).buffer` instead. + */ export function stringToFileBuffer(str: string): FileBuffer { - // If we're in Node... - if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') { - const buf = Buffer.from(str); - const ab = new ArrayBuffer(buf.length); - const view = new Uint8Array(ab); - for (let i = 0; i < buf.length; ++i) { - view[i] = buf[i]; - } - - return ab; - } else if (typeof TextEncoder !== 'undefined') { - // Modern browsers implement TextEncode. - return new TextEncoder('utf-8').encode(str).buffer as ArrayBuffer; - } else { - // Slowest method but sure to be compatible with every platform. - const buf = new ArrayBuffer(str.length * 2); // 2 bytes for each char - const bufView = new Uint16Array(buf); - for (let i = 0, strLen = str.length; i < strLen; i++) { - bufView[i] = str.charCodeAt(i); - } - - return buf; - } + return new TextEncoder().encode(str).buffer; } - -export const fileBuffer: TemplateTag = (strings, ...values) => { - return stringToFileBuffer(String.raw(strings, ...values)); -}; - - +/** + * @deprecated Use `new TextDecoder().decode(fileBuffer)` instead. + */ export function fileBufferToString(fileBuffer: FileBuffer): string { - if (fileBuffer.toString.length == 1) { + if (fileBuffer.toString.length === 1) { return (fileBuffer.toString as (enc: string) => string)('utf-8'); - } else if (typeof Buffer !== 'undefined') { - return Buffer.from(fileBuffer).toString('utf-8'); - } else if (typeof TextDecoder !== 'undefined') { - // Modern browsers implement TextEncode. - return new TextDecoder('utf-8').decode(new Uint8Array(fileBuffer)); - } else { - // Slowest method but sure to be compatible with every platform. - const bufView = new Uint8Array(fileBuffer); - const bufLength = bufView.length; - let result = ''; - let chunkLength = Math.pow(2, 16) - 1; - - // We have to chunk it because String.fromCharCode.apply will throw - // `Maximum call stack size exceeded` on big inputs. - for (let i = 0; i < bufLength; i += chunkLength) { - if (i + chunkLength > bufLength) { - chunkLength = bufLength - i; - } - result += String.fromCharCode.apply(null, bufView.subarray(i, i + chunkLength)); - } - - return result; } + + return new TextDecoder('utf-8').decode(new Uint8Array(fileBuffer)); } diff --git a/packages/angular_devkit/core/src/virtual-fs/host/create.ts b/packages/angular_devkit/core/src/virtual-fs/host/create.ts new file mode 100644 index 000000000000..342f54e7870b --- /dev/null +++ b/packages/angular_devkit/core/src/virtual-fs/host/create.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Observable } from 'rxjs'; +import { Path, PathFragment } from '../path'; +import { FileBuffer, FileBufferLike, Host, HostCapabilities, Stats } from './interface'; + +export interface SyncHostHandler { + read(path: Path): FileBuffer; + list(path: Path): PathFragment[]; + + exists(path: Path): boolean; + isDirectory(path: Path): boolean; + isFile(path: Path): boolean; + + stat(path: Path): Stats | null; + + write(path: Path, content: FileBufferLike): void; + delete(path: Path): void; + rename(from: Path, to: Path): void; +} + +function wrapAction(action: () => T): Observable { + return new Observable((subscriber) => { + subscriber.next(action()); + subscriber.complete(); + }); +} + +export function createSyncHost( + handler: SyncHostHandler, +): Host { + return new (class { + get capabilities(): HostCapabilities { + return { synchronous: true }; + } + + read(path: Path): Observable { + return wrapAction(() => handler.read(path)); + } + + list(path: Path): Observable { + return wrapAction(() => handler.list(path)); + } + + exists(path: Path): Observable { + return wrapAction(() => handler.exists(path)); + } + + isDirectory(path: Path): Observable { + return wrapAction(() => handler.isDirectory(path)); + } + + isFile(path: Path): Observable { + return wrapAction(() => handler.isFile(path)); + } + + stat(path: Path): Observable | null> { + return wrapAction(() => handler.stat(path)); + } + + write(path: Path, content: FileBufferLike): Observable { + return wrapAction(() => handler.write(path, content)); + } + + delete(path: Path): Observable { + return wrapAction(() => handler.delete(path)); + } + + rename(from: Path, to: Path): Observable { + return wrapAction(() => handler.rename(from, to)); + } + + watch(): null { + return null; + } + })(); +} diff --git a/packages/angular_devkit/core/src/virtual-fs/host/empty.ts b/packages/angular_devkit/core/src/virtual-fs/host/empty.ts index 791b27c6e7cf..6b70c6906261 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/empty.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/empty.ts @@ -1,10 +1,11 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { Observable, of, throwError } from 'rxjs'; import { FileDoesNotExistException } from '../../exception'; import { Path, PathFragment } from '../path'; diff --git a/packages/angular_devkit/core/src/virtual-fs/host/index.ts b/packages/angular_devkit/core/src/virtual-fs/host/index.ts index f7c963dcfc86..a7e9d554e41e 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/index.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/index.ts @@ -1,13 +1,16 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 test from './test'; + export * from './alias'; export * from './buffer'; +export * from './create'; export * from './empty'; export * from './interface'; export * from './memory'; @@ -17,7 +20,4 @@ export * from './safe'; export * from './scoped'; export * from './sync'; export * from './resolver'; - -import * as test from './test'; - export { test }; diff --git a/packages/angular_devkit/core/src/virtual-fs/host/interface.ts b/packages/angular_devkit/core/src/virtual-fs/host/interface.ts index 9c48e58f5d0d..f8ce6f9eb020 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/interface.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/interface.ts @@ -1,14 +1,14 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { Observable } from 'rxjs'; import { Path, PathFragment } from '../path'; - export type FileBuffer = ArrayBuffer; export type FileBufferLike = ArrayBufferLike; @@ -17,12 +17,11 @@ export interface HostWatchOptions { readonly recursive?: boolean; } - -export const enum HostWatchEventType { +export enum HostWatchEventType { Changed = 0, Created = 1, Deleted = 2, - Renamed = 3, // Applied to the original file path. + Renamed = 3, // Applied to the original file path. } export type Stats = T & { diff --git a/packages/angular_devkit/core/src/virtual-fs/host/memory.ts b/packages/angular_devkit/core/src/virtual-fs/host/memory.ts index 02fb40b869d2..0b9e1285b6ad 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/memory.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/memory.ts @@ -1,10 +1,11 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { Observable, Subject } from 'rxjs'; import { FileAlreadyExistException, @@ -33,21 +34,27 @@ import { Stats, } from './interface'; - export interface SimpleMemoryHostStats { readonly content: FileBuffer | null; + inspect(): string; } export class SimpleMemoryHost implements Host<{}> { - protected _cache = new Map>(); + protected _cache: Map> = new Map(); private _watchers = new Map][]>(); - protected _newDirStats() { + protected _newDirStats(): Stats { return { - inspect() { return ''; }, - - isFile() { return false; }, - isDirectory() { return true; }, + inspect(): string { + return ''; + }, + + isFile() { + return false; + }, + isDirectory() { + return true; + }, size: 0, atime: new Date(), @@ -58,12 +65,21 @@ export class SimpleMemoryHost implements Host<{}> { content: null, }; } - protected _newFileStats(content: FileBuffer, oldStats?: Stats) { + protected _newFileStats( + content: FileBuffer, + oldStats?: Stats, + ): Stats { return { - inspect() { return ``; }, - - isFile() { return true; }, - isDirectory() { return false; }, + inspect() { + return ``; + }, + + isFile() { + return true; + }, + isDirectory() { + return false; + }, size: content.byteLength, atime: oldStats ? oldStats.atime : new Date(), @@ -79,11 +95,11 @@ export class SimpleMemoryHost implements Host<{}> { this._cache.set(normalize('/'), this._newDirStats()); } - protected _toAbsolute(path: Path) { + protected _toAbsolute(path: Path): Path { return isAbsolute(path) ? path : normalize('/' + path); } - protected _updateWatchers(path: Path, type: HostWatchEventType) { + protected _updateWatchers(path: Path, type: HostWatchEventType): void { const time = new Date(); let currentPath = path; let parent: Path | null = null; @@ -95,7 +111,7 @@ export class SimpleMemoryHost implements Host<{}> { const maybeWatcher = this._watchers.get(currentPath); if (maybeWatcher) { - maybeWatcher.forEach(watcher => { + maybeWatcher.forEach((watcher) => { const [options, subject] = watcher; subject.next({ path, time, type }); @@ -112,7 +128,7 @@ export class SimpleMemoryHost implements Host<{}> { const maybeWatcher = this._watchers.get(currentPath); if (maybeWatcher) { - maybeWatcher.forEach(watcher => { + maybeWatcher.forEach((watcher) => { const [options, subject] = watcher; if (!options.recursive) { return; @@ -212,7 +228,7 @@ export class SimpleMemoryHost implements Host<{}> { const content = this._cache.get(from); if (content) { const fragments = split(to); - const newDirectories = []; + const newDirectories: Path[] = []; let curr: Path = normalize('/'); for (const fr of fragments) { curr = join(curr, fr); @@ -301,7 +317,7 @@ export class SimpleMemoryHost implements Host<{}> { } write(path: Path, content: FileBuffer): Observable { - return new Observable(obs => { + return new Observable((obs) => { this._write(path, content); obs.next(); obs.complete(); @@ -309,7 +325,7 @@ export class SimpleMemoryHost implements Host<{}> { } read(path: Path): Observable { - return new Observable(obs => { + return new Observable((obs) => { const content = this._read(path); obs.next(content); obs.complete(); @@ -317,7 +333,7 @@ export class SimpleMemoryHost implements Host<{}> { } delete(path: Path): Observable { - return new Observable(obs => { + return new Observable((obs) => { this._delete(path); obs.next(); obs.complete(); @@ -325,7 +341,7 @@ export class SimpleMemoryHost implements Host<{}> { } rename(from: Path, to: Path): Observable { - return new Observable(obs => { + return new Observable((obs) => { this._rename(from, to); obs.next(); obs.complete(); @@ -333,28 +349,28 @@ export class SimpleMemoryHost implements Host<{}> { } list(path: Path): Observable { - return new Observable(obs => { + return new Observable((obs) => { obs.next(this._list(path)); obs.complete(); }); } exists(path: Path): Observable { - return new Observable(obs => { + return new Observable((obs) => { obs.next(this._exists(path)); obs.complete(); }); } isDirectory(path: Path): Observable { - return new Observable(obs => { + return new Observable((obs) => { obs.next(this._isDirectory(path)); obs.complete(); }); } isFile(path: Path): Observable { - return new Observable(obs => { + return new Observable((obs) => { obs.next(this._isFile(path)); obs.complete(); }); @@ -362,7 +378,7 @@ export class SimpleMemoryHost implements Host<{}> { // Some hosts may not support stat. stat(path: Path): Observable | null> | null { - return new Observable | null>(obs => { + return new Observable | null>((obs) => { obs.next(this._stat(path)); obs.complete(); }); @@ -371,4 +387,9 @@ export class SimpleMemoryHost implements Host<{}> { watch(path: Path, options?: HostWatchOptions): Observable | null { return this._watch(path, options); } + + reset(): void { + this._cache.clear(); + this._watchers.clear(); + } } diff --git a/packages/angular_devkit/core/src/virtual-fs/host/memory_spec.ts b/packages/angular_devkit/core/src/virtual-fs/host/memory_spec.ts index bf9df7a106c9..fce1623737a1 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/memory_spec.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/memory_spec.ts @@ -1,42 +1,40 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -// tslint:disable:no-implicit-dependencies -// tslint:disable:no-non-null-assertion -import { fragment, normalize } from '@angular-devkit/core'; -import { stringToFileBuffer } from './buffer'; + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { fragment, normalize } from '../path'; import { SimpleMemoryHost } from './memory'; import { SyncDelegateHost } from './sync'; describe('SimpleMemoryHost', () => { - it('can watch', () => { const host = new SyncDelegateHost(new SimpleMemoryHost()); - host.write(normalize('/sub/file1'), stringToFileBuffer('')); + host.write(normalize('/sub/file1'), new TextEncoder().encode('').buffer); let recursiveCalled = 0; let noRecursiveCalled = 0; let noRecursiveFileCalled = 0; let diffFile = 0; - host.watch(normalize('/sub'), { recursive: true }) !.subscribe(() => recursiveCalled++); - host.watch(normalize('/sub')) !.subscribe(() => noRecursiveCalled++); - host.watch(normalize('/sub/file2')) !.subscribe(() => noRecursiveFileCalled++); - host.watch(normalize('/sub/file3')) !.subscribe(() => diffFile++); + host.watch(normalize('/sub'), { recursive: true })!.subscribe(() => recursiveCalled++); + host.watch(normalize('/sub'))!.subscribe(() => noRecursiveCalled++); + host.watch(normalize('/sub/file2'))!.subscribe(() => noRecursiveFileCalled++); + host.watch(normalize('/sub/file3'))!.subscribe(() => diffFile++); - host.write(normalize('/sub/file2'), stringToFileBuffer('')); + host.write(normalize('/sub/file2'), new TextEncoder().encode('').buffer); expect(recursiveCalled).toBe(1); expect(noRecursiveCalled).toBe(0); expect(noRecursiveFileCalled).toBe(1); expect(diffFile).toBe(0); - host.write(normalize('/sub/file3'), stringToFileBuffer('')); + host.write(normalize('/sub/file3'), new TextEncoder().encode('').buffer); expect(recursiveCalled).toBe(2); expect(noRecursiveCalled).toBe(0); @@ -47,7 +45,7 @@ describe('SimpleMemoryHost', () => { it('can read', () => { const host = new SyncDelegateHost(new SimpleMemoryHost()); - const buffer = stringToFileBuffer('hello'); + const buffer = new TextEncoder().encode('hello').buffer; host.write(normalize('/hello'), buffer); expect(host.read(normalize('/hello'))).toBe(buffer); @@ -56,7 +54,7 @@ describe('SimpleMemoryHost', () => { it('can delete', () => { const host = new SyncDelegateHost(new SimpleMemoryHost()); - const buffer = stringToFileBuffer('hello'); + const buffer = new TextEncoder().encode('hello').buffer; expect(host.exists(normalize('/sub/file1'))).toBe(false); host.write(normalize('/sub/file1'), buffer); @@ -68,7 +66,7 @@ describe('SimpleMemoryHost', () => { it('can delete directory', () => { const host = new SyncDelegateHost(new SimpleMemoryHost()); - const buffer = stringToFileBuffer('hello'); + const buffer = new TextEncoder().encode('hello').buffer; expect(host.exists(normalize('/sub/file1'))).toBe(false); host.write(normalize('/sub/file1'), buffer); @@ -84,7 +82,7 @@ describe('SimpleMemoryHost', () => { it('can rename', () => { const host = new SyncDelegateHost(new SimpleMemoryHost()); - const buffer = stringToFileBuffer('hello'); + const buffer = new TextEncoder().encode('hello').buffer; expect(host.exists(normalize('/sub/file1'))).toBe(false); host.write(normalize('/sub/file1'), buffer); @@ -98,24 +96,26 @@ describe('SimpleMemoryHost', () => { it('can list', () => { const host = new SyncDelegateHost(new SimpleMemoryHost()); - const buffer = stringToFileBuffer('hello'); + const buffer = new TextEncoder().encode('hello').buffer; host.write(normalize('/sub/file1'), buffer); host.write(normalize('/sub/file2'), buffer); host.write(normalize('/sub/sub1/file3'), buffer); host.write(normalize('/file4'), buffer); - expect(host.list(normalize('/sub'))) - .toEqual([fragment('file1'), fragment('file2'), fragment('sub1')]); - expect(host.list(normalize('/'))) - .toEqual([fragment('sub'), fragment('file4')]); + expect(host.list(normalize('/sub'))).toEqual([ + fragment('file1'), + fragment('file2'), + fragment('sub1'), + ]); + expect(host.list(normalize('/'))).toEqual([fragment('sub'), fragment('file4')]); expect(host.list(normalize('/inexistent'))).toEqual([]); }); it('supports isFile / isDirectory', () => { const host = new SyncDelegateHost(new SimpleMemoryHost()); - const buffer = stringToFileBuffer('hello'); + const buffer = new TextEncoder().encode('hello').buffer; host.write(normalize('/sub/file1'), buffer); host.write(normalize('/sub/file2'), buffer); @@ -134,8 +134,8 @@ describe('SimpleMemoryHost', () => { it('makes every path absolute', () => { const host = new SyncDelegateHost(new SimpleMemoryHost()); - const buffer = stringToFileBuffer('hello'); - const buffer2 = stringToFileBuffer('hello 2'); + const buffer = new TextEncoder().encode('hello').buffer; + const buffer2 = new TextEncoder().encode('hello 2').buffer; host.write(normalize('file1'), buffer); host.write(normalize('/sub/file2'), buffer); diff --git a/packages/angular_devkit/core/src/virtual-fs/host/pattern.ts b/packages/angular_devkit/core/src/virtual-fs/host/pattern.ts index 9880c1e67903..f34899cf1d7c 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/pattern.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/pattern.ts @@ -1,40 +1,31 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { parse as parseGlob } from 'picomatch'; import { Path } from '../path'; import { ResolverHost } from './resolver'; - export type ReplacementFunction = (path: Path) => Path; - /** */ export class PatternMatchingHost extends ResolverHost { - protected _patterns = new Map(); - - addPattern(pattern: string | string[], replacementFn: ReplacementFunction) { - // Simple GLOB pattern replacement. - const reString = '^(' - + (Array.isArray(pattern) ? pattern : [pattern]) - .map(ex => '(' - + ex.split(/[\/\\]/g).map(f => f - .replace(/[\-\[\]{}()+?.^$|]/g, '\\$&') - .replace(/^\*\*/g, '(.+?)?') - .replace(/\*/g, '[^/\\\\]*')) - .join('[\/\\\\]') - + ')') - .join('|') - + ')($|/|\\\\)'; - - this._patterns.set(new RegExp(reString), replacementFn); + protected _patterns: Map = new Map(); + + addPattern(pattern: string | string[], replacementFn: ReplacementFunction): void { + const patterns = Array.isArray(pattern) ? pattern : [pattern]; + for (const glob of patterns) { + const { output } = parseGlob(glob); + this._patterns.set(new RegExp(`^${output}$`), replacementFn); + } } - protected _resolve(path: Path) { + protected _resolve(path: Path): Path { let newPath = path; this._patterns.forEach((fn, re) => { if (re.test(path)) { diff --git a/packages/angular_devkit/core/src/virtual-fs/host/pattern_spec.ts b/packages/angular_devkit/core/src/virtual-fs/host/pattern_spec.ts index eb10ea21c1e0..56abcff14ef2 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/pattern_spec.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/pattern_spec.ts @@ -1,44 +1,41 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 '..'; -import { stringToFileBuffer } from './buffer'; import { SimpleMemoryHost } from './memory'; import { PatternMatchingHost } from './pattern'; describe('PatternMatchingHost', () => { it('works for NativeScript', () => { - const content = stringToFileBuffer('hello world'); - const content2 = stringToFileBuffer('hello world 2'); + const content = new TextEncoder().encode('hello world').buffer; + const content2 = new TextEncoder().encode('hello world 2').buffer; const host = new SimpleMemoryHost(); host.write(normalize('/some/file.tns.ts'), content).subscribe(); const pHost = new PatternMatchingHost(host); - pHost.read(normalize('/some/file.tns.ts')) - .subscribe(x => expect(x).toBe(content)); + pHost.read(normalize('/some/file.tns.ts')).subscribe((x) => expect(x).toBe(content)); - pHost.addPattern('**/*.tns.ts', path => { + pHost.addPattern('**/*.tns.ts', (path) => { return normalize(path.replace(/\.tns\.ts$/, '.ts')); }); // This file will not exist because /some/file.ts does not exist. try { - pHost.read(normalize('/some/file.tns.ts')) - .subscribe(undefined, err => { - expect(err.message).toMatch(/does not exist/); - }); + pHost.read(normalize('/some/file.tns.ts')).subscribe(undefined, (err) => { + expect(err.message).toMatch(/does not exist/); + }); } catch { // Ignore it. RxJS <6 still throw errors when they happen synchronously. } // Create the file, it should exist now. pHost.write(normalize('/some/file.ts'), content2).subscribe(); - pHost.read(normalize('/some/file.tns.ts')) - .subscribe(x => expect(x).toBe(content2)); + pHost.read(normalize('/some/file.tns.ts')).subscribe((x) => expect(x).toBe(content2)); }); }); diff --git a/packages/angular_devkit/core/src/virtual-fs/host/record.ts b/packages/angular_devkit/core/src/virtual-fs/host/record.ts index 9396dbda8a2d..98a445385bbb 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/record.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/record.ts @@ -1,18 +1,24 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { + EMPTY, Observable, concat, + concatMap, + map, from as observableFrom, of, + reduce, + switchMap, throwError, + toArray, } from 'rxjs'; -import { concatMap, map, reduce, switchMap, toArray } from 'rxjs/operators'; import { FileAlreadyExistException, FileDoesNotExistException, @@ -30,7 +36,6 @@ import { } from './interface'; import { SimpleMemoryHost } from './memory'; - export interface CordHostCreate { kind: 'create'; path: Path; @@ -50,11 +55,7 @@ export interface CordHostDelete { kind: 'delete'; path: Path; } -export type CordHostRecord = CordHostCreate - | CordHostOverwrite - | CordHostRename - | CordHostDelete; - +export type CordHostRecord = CordHostCreate | CordHostOverwrite | CordHostRename | CordHostDelete; /** * A Host that records changes to the underlying Host, while keeping a record of Create, Overwrite, @@ -67,16 +68,20 @@ export type CordHostRecord = CordHostCreate * the create/overwrite records IIF the files does/doesn't already exist. */ export class CordHost extends SimpleMemoryHost { - protected _filesToCreate = new Set(); - protected _filesToRename = new Map(); - protected _filesToRenameRevert = new Map(); - protected _filesToDelete = new Set(); - protected _filesToOverwrite = new Set(); - - constructor(protected _back: ReadonlyHost) { super(); } + protected _filesToCreate: Set = new Set(); + protected _filesToRename: Map = new Map(); + protected _filesToRenameRevert: Map = new Map(); + protected _filesToDelete: Set = new Set(); + protected _filesToOverwrite: Set = new Set(); + + constructor(protected _back: ReadonlyHost) { + super(); + } - get backend(): ReadonlyHost { return this._back; } - get capabilities(): HostCapabilities { + get backend(): ReadonlyHost { + return this._back; + } + override get capabilities(): HostCapabilities { // Our own host is always Synchronous, but the backend might not be. return { synchronous: this._back.capabilities.synchronous, @@ -112,13 +117,15 @@ export class CordHost extends SimpleMemoryHost { commit(host: Host, force = false): Observable { // Really commit everything to the actual host. return observableFrom(this.records()).pipe( - concatMap(record => { + concatMap((record) => { switch (record.kind) { - case 'delete': return host.delete(record.path); - case 'rename': return host.rename(record.from, record.to); + case 'delete': + return host.delete(record.path); + case 'rename': + return host.rename(record.from, record.to); case 'create': return host.exists(record.path).pipe( - switchMap(exists => { + switchMap((exists) => { if (exists && !force) { return throwError(new FileAlreadyExistException(record.path)); } else { @@ -128,7 +135,7 @@ export class CordHost extends SimpleMemoryHost { ); case 'overwrite': return host.exists(record.path).pipe( - switchMap(exists => { + switchMap((exists) => { if (!exists && !force) { return throwError(new FileDoesNotExistException(record.path)); } else { @@ -144,18 +151,37 @@ export class CordHost extends SimpleMemoryHost { records(): CordHostRecord[] { return [ - ...[...this._filesToDelete.values()].map(path => ({ - kind: 'delete', path, - }) as CordHostRecord), - ...[...this._filesToRename.entries()].map(([from, to]) => ({ - kind: 'rename', from, to, - }) as CordHostRecord), - ...[...this._filesToCreate.values()].map(path => ({ - kind: 'create', path, content: this._read(path), - }) as CordHostRecord), - ...[...this._filesToOverwrite.values()].map(path => ({ - kind: 'overwrite', path, content: this._read(path), - }) as CordHostRecord), + ...[...this._filesToDelete.values()].map( + (path) => + ({ + kind: 'delete', + path, + }) as CordHostRecord, + ), + ...[...this._filesToRename.entries()].map( + ([from, to]) => + ({ + kind: 'rename', + from, + to, + }) as CordHostRecord, + ), + ...[...this._filesToCreate.values()].map( + (path) => + ({ + kind: 'create', + path, + content: this._read(path), + }) as CordHostRecord, + ), + ...[...this._filesToOverwrite.values()].map( + (path) => + ({ + kind: 'overwrite', + path, + content: this._read(path), + }) as CordHostRecord, + ), ]; } @@ -183,14 +209,14 @@ export class CordHost extends SimpleMemoryHost { overwrite(path: Path, content: FileBuffer): Observable { return this.isDirectory(path).pipe( - switchMap(isDir => { + switchMap((isDir) => { if (isDir) { return throwError(new PathIsDirectoryException(path)); } return this.exists(path); }), - switchMap(exists => { + switchMap((exists) => { if (!exists) { return throwError(new FileDoesNotExistException(path)); } @@ -204,9 +230,9 @@ export class CordHost extends SimpleMemoryHost { ); } - write(path: Path, content: FileBuffer): Observable { + override write(path: Path, content: FileBuffer): Observable { return this.exists(path).pipe( - switchMap(exists => { + switchMap((exists) => { if (exists) { // It exists, but might be being renamed or deleted. In that case we want to create it. if (this.willRename(path) || this.willDelete(path)) { @@ -221,7 +247,7 @@ export class CordHost extends SimpleMemoryHost { ); } - read(path: Path): Observable { + override read(path: Path): Observable { if (this._exists(path)) { return super.read(path); } @@ -229,7 +255,7 @@ export class CordHost extends SimpleMemoryHost { return this._back.read(path); } - delete(path: Path): Observable { + override delete(path: Path): Observable { if (this._exists(path)) { if (this._filesToCreate.has(path)) { this._filesToCreate.delete(path); @@ -252,7 +278,7 @@ export class CordHost extends SimpleMemoryHost { return super.delete(path); } else { return this._back.exists(path).pipe( - switchMap(exists => { + switchMap((exists) => { if (exists) { this._filesToDelete.add(path); @@ -265,18 +291,15 @@ export class CordHost extends SimpleMemoryHost { } } - rename(from: Path, to: Path): Observable { - return concat( - this.exists(to), - this.exists(from), - ).pipe( + override rename(from: Path, to: Path): Observable { + return concat(this.exists(to), this.exists(from)).pipe( toArray(), switchMap(([existTo, existFrom]) => { if (!existFrom) { return throwError(new FileDoesNotExistException(from)); } if (from === to) { - return of(); + return EMPTY; } if (existTo) { @@ -297,7 +320,7 @@ export class CordHost extends SimpleMemoryHost { // if will be by-passed because we just deleted the `from` path from files to overwrite. return concat( this.rename(from, to), - new Observable(x => { + new Observable((x) => { this._filesToOverwrite.add(to); x.complete(); }), @@ -309,9 +332,7 @@ export class CordHost extends SimpleMemoryHost { this._filesToOverwrite.add(to); // We need to delete the original and write the new one. - return this.read(from).pipe( - map(content => this._write(to, content)), - ); + return this.read(from).pipe(map((content) => this._write(to, content))); } const maybeTo1 = this._filesToRenameRevert.get(from); @@ -331,66 +352,67 @@ export class CordHost extends SimpleMemoryHost { return super.rename(from, to); } else { // Create a file with the same content. - return this._back.read(from).pipe( - switchMap(content => super.write(to, content)), - ); + return this._back.read(from).pipe(switchMap((content) => super.write(to, content))); } }), ); } - list(path: Path): Observable { - return concat( - super.list(path), - this._back.list(path), - ).pipe( + override list(path: Path): Observable { + return concat(super.list(path), this._back.list(path)).pipe( reduce((list: Set, curr: PathFragment[]) => { - curr.forEach(elem => list.add(elem)); + curr.forEach((elem) => list.add(elem)); return list; }, new Set()), - map(set => [...set]), + map((set) => [...set]), ); } - exists(path: Path): Observable { + override exists(path: Path): Observable { return this._exists(path) ? of(true) - : ((this.willDelete(path) || this.willRename(path)) ? of(false) : this._back.exists(path)); + : this.willDelete(path) || this.willRename(path) + ? of(false) + : this._back.exists(path); } - isDirectory(path: Path): Observable { + override isDirectory(path: Path): Observable { return this._exists(path) ? super.isDirectory(path) : this._back.isDirectory(path); } - isFile(path: Path): Observable { + override isFile(path: Path): Observable { return this._exists(path) ? super.isFile(path) - : ((this.willDelete(path) || this.willRename(path)) ? of(false) : this._back.isFile(path)); + : this.willDelete(path) || this.willRename(path) + ? of(false) + : this._back.isFile(path); } - stat(path: Path): Observable | null { + override stat(path: Path): Observable | null { return this._exists(path) ? super.stat(path) - : ((this.willDelete(path) || this.willRename(path)) ? of(null) : this._back.stat(path)); + : this.willDelete(path) || this.willRename(path) + ? of(null) + : this._back.stat(path); } - watch(path: Path, options?: HostWatchOptions) { + override watch(path: Path, options?: HostWatchOptions) { // Watching not supported. return null; } - willCreate(path: Path) { + willCreate(path: Path): boolean { return this._filesToCreate.has(path); } - willOverwrite(path: Path) { + willOverwrite(path: Path): boolean { return this._filesToOverwrite.has(path); } - willDelete(path: Path) { + willDelete(path: Path): boolean { return this._filesToDelete.has(path); } - willRename(path: Path) { + willRename(path: Path): boolean { return this._filesToRename.has(path); } - willRenameTo(path: Path, to: Path) { + willRenameTo(path: Path, to: Path): boolean { return this._filesToRename.get(path) === to; } } diff --git a/packages/angular_devkit/core/src/virtual-fs/host/record_spec.ts b/packages/angular_devkit/core/src/virtual-fs/host/record_spec.ts index 96b6b0e43eaa..dd50be84f966 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/record_spec.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/record_spec.ts @@ -1,32 +1,31 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -// tslint:disable:no-any no-big-function + import { path } from '../path'; -import { fileBuffer } from './buffer'; import { CordHost } from './record'; -import { TestHost } from './test'; - +import * as test from './test'; describe('CordHost', () => { + const TestHost = test.TestHost; const mutatingTestRecord = ['write', 'delete', 'rename']; - it('works (create)', done => { + it('works (create)', (done) => { const base = new TestHost({ '/hello': 'world', }); const host = new CordHost(base); - host.write(path`/blue`, fileBuffer`hi`).subscribe(undefined, done.fail); + host.write(path`/blue`, new TextEncoder().encode(`hi`).buffer).subscribe(undefined, done.fail); const target = new TestHost(); host.commit(target).subscribe(undefined, done.fail); - expect(target.records.filter(x => mutatingTestRecord.includes(x.kind))).toEqual([ + expect(target.records.filter((x) => mutatingTestRecord.includes(x.kind))).toEqual([ { kind: 'write', path: path`/blue` }, ]); @@ -35,19 +34,21 @@ describe('CordHost', () => { done(); }); - it('works (create -> create)', done => { + it('works (create -> create)', (done) => { const base = new TestHost({ '/hello': 'world', }); const host = new CordHost(base); - host.write(path`/blue`, fileBuffer`hi`).subscribe(undefined, done.fail); - host.write(path`/blue`, fileBuffer`hi again`).subscribe(undefined, done.fail); + host.write(path`/blue`, new TextEncoder().encode(`hi`).buffer).subscribe(undefined, done.fail); + host + .write(path`/blue`, new TextEncoder().encode(`hi again`).buffer) + .subscribe(undefined, done.fail); const target = new TestHost(); host.commit(target).subscribe(undefined, done.fail); - expect(target.records.filter(x => mutatingTestRecord.includes(x.kind))).toEqual([ + expect(target.records.filter((x) => mutatingTestRecord.includes(x.kind))).toEqual([ { kind: 'write', path: path`/blue` }, ]); @@ -57,40 +58,39 @@ describe('CordHost', () => { done(); }); - it('works (create -> delete)', done => { + it('works (create -> delete)', (done) => { const base = new TestHost({ '/hello': 'world', }); const host = new CordHost(base); - host.write(path`/blue`, fileBuffer`hi`).subscribe(undefined, done.fail); + host.write(path`/blue`, new TextEncoder().encode(`hi`).buffer).subscribe(undefined, done.fail); host.delete(path`/blue`).subscribe(undefined, done.fail); const target = new TestHost(); host.commit(target).subscribe(undefined, done.fail); - expect(target.records.filter(x => mutatingTestRecord.includes(x.kind))).toEqual([ - ]); + expect(target.records.filter((x) => mutatingTestRecord.includes(x.kind))).toEqual([]); expect(target.$exists('/hello')).toBe(false); expect(target.$exists('/blue')).toBe(false); done(); }); - it('works (create -> rename)', done => { + it('works (create -> rename)', (done) => { const base = new TestHost({ '/hello': 'world', }); const host = new CordHost(base); - host.write(path`/blue`, fileBuffer`hi`).subscribe(undefined, done.fail); + host.write(path`/blue`, new TextEncoder().encode(`hi`).buffer).subscribe(undefined, done.fail); host.rename(path`/blue`, path`/red`).subscribe(undefined, done.fail); const target = new TestHost(); host.commit(target).subscribe(undefined, done.fail); // Check that there's only 1 write done. - expect(target.records.filter(x => mutatingTestRecord.includes(x.kind))).toEqual([ + expect(target.records.filter((x) => mutatingTestRecord.includes(x.kind))).toEqual([ { kind: 'write', path: path`/red` }, ]); @@ -101,20 +101,20 @@ describe('CordHost', () => { done(); }); - it('works (create -> rename (identity))', done => { + it('works (create -> rename (identity))', (done) => { const base = new TestHost({ '/hello': 'world', }); const host = new CordHost(base); - host.write(path`/blue`, fileBuffer`hi`).subscribe(undefined, done.fail); + host.write(path`/blue`, new TextEncoder().encode(`hi`).buffer).subscribe(undefined, done.fail); host.rename(path`/blue`, path`/blue`).subscribe(undefined, done.fail); const target = new TestHost(); host.commit(target).subscribe(undefined, done.fail); // Check that there's only 1 write done. - expect(target.records.filter(x => mutatingTestRecord.includes(x.kind))).toEqual([ + expect(target.records.filter((x) => mutatingTestRecord.includes(x.kind))).toEqual([ { kind: 'write', path: path`/blue` }, ]); @@ -124,13 +124,13 @@ describe('CordHost', () => { done(); }); - it('works (create -> rename -> rename)', done => { + it('works (create -> rename -> rename)', (done) => { const base = new TestHost({ '/hello': 'world', }); const host = new CordHost(base); - host.write(path`/blue`, fileBuffer`hi`).subscribe(undefined, done.fail); + host.write(path`/blue`, new TextEncoder().encode(`hi`).buffer).subscribe(undefined, done.fail); host.rename(path`/blue`, path`/red`).subscribe(undefined, done.fail); host.rename(path`/red`, path`/yellow`).subscribe(undefined, done.fail); @@ -138,7 +138,7 @@ describe('CordHost', () => { host.commit(target).subscribe(undefined, done.fail); // Check that there's only 1 write done. - expect(target.records.filter(x => mutatingTestRecord.includes(x.kind))).toEqual([ + expect(target.records.filter((x) => mutatingTestRecord.includes(x.kind))).toEqual([ { kind: 'write', path: path`/yellow` }, ]); @@ -150,7 +150,7 @@ describe('CordHost', () => { done(); }); - it('works (rename)', done => { + it('works (rename)', (done) => { const base = new TestHost({ '/hello': 'world', }); @@ -162,7 +162,7 @@ describe('CordHost', () => { host.commit(target).subscribe(undefined, done.fail); // Check that there's only 1 write done. - expect(target.records.filter(x => mutatingTestRecord.includes(x.kind))).toEqual([ + expect(target.records.filter((x) => mutatingTestRecord.includes(x.kind))).toEqual([ { kind: 'rename', from: path`/hello`, to: path`/blue` }, ]); @@ -172,7 +172,7 @@ describe('CordHost', () => { done(); }); - it('works (rename -> rename)', done => { + it('works (rename -> rename)', (done) => { const base = new TestHost({ '/hello': 'world', }); @@ -185,7 +185,7 @@ describe('CordHost', () => { host.commit(target).subscribe(undefined, done.fail); // Check that there's only 1 write done. - expect(target.records.filter(x => mutatingTestRecord.includes(x.kind))).toEqual([ + expect(target.records.filter((x) => mutatingTestRecord.includes(x.kind))).toEqual([ { kind: 'rename', from: path`/hello`, to: path`/red` }, ]); @@ -196,20 +196,22 @@ describe('CordHost', () => { done(); }); - it('works (rename -> create)', done => { + it('works (rename -> create)', (done) => { const base = new TestHost({ '/hello': 'world', }); const host = new CordHost(base); host.rename(path`/hello`, path`/blue`).subscribe(undefined, done.fail); - host.write(path`/hello`, fileBuffer`beautiful world`).subscribe(undefined, done.fail); + host + .write(path`/hello`, new TextEncoder().encode(`beautiful world`).buffer) + .subscribe(undefined, done.fail); const target = base.clone(); host.commit(target).subscribe(undefined, done.fail); // Check that there's only 1 write done. - expect(target.records.filter(x => mutatingTestRecord.includes(x.kind))).toEqual([ + expect(target.records.filter((x) => mutatingTestRecord.includes(x.kind))).toEqual([ { kind: 'rename', from: path`/hello`, to: path`/blue` }, { kind: 'write', path: path`/hello` }, ]); @@ -220,19 +222,21 @@ describe('CordHost', () => { done(); }); - it('works (overwrite)', done => { + it('works (overwrite)', (done) => { const base = new TestHost({ '/hello': 'world', }); const host = new CordHost(base); - host.write(path`/hello`, fileBuffer`beautiful world`).subscribe(undefined, done.fail); + host + .write(path`/hello`, new TextEncoder().encode(`beautiful world`).buffer) + .subscribe(undefined, done.fail); const target = base.clone(); host.commit(target).subscribe(undefined, done.fail); // Check that there's only 1 write done. - expect(target.records.filter(x => mutatingTestRecord.includes(x.kind))).toEqual([ + expect(target.records.filter((x) => mutatingTestRecord.includes(x.kind))).toEqual([ { kind: 'write', path: path`/hello` }, ]); @@ -242,20 +246,24 @@ describe('CordHost', () => { done(); }); - it('works (overwrite -> overwrite)', done => { + it('works (overwrite -> overwrite)', (done) => { const base = new TestHost({ '/hello': 'world', }); const host = new CordHost(base); - host.write(path`/hello`, fileBuffer`beautiful world`).subscribe(undefined, done.fail); - host.write(path`/hello`, fileBuffer`again`).subscribe(undefined, done.fail); + host + .write(path`/hello`, new TextEncoder().encode(`beautiful world`).buffer) + .subscribe(undefined, done.fail); + host + .write(path`/hello`, new TextEncoder().encode(`again`).buffer) + .subscribe(undefined, done.fail); const target = base.clone(); host.commit(target).subscribe(undefined, done.fail); // Check that there's only 1 write done. - expect(target.records.filter(x => mutatingTestRecord.includes(x.kind))).toEqual([ + expect(target.records.filter((x) => mutatingTestRecord.includes(x.kind))).toEqual([ { kind: 'write', path: path`/hello` }, ]); @@ -265,19 +273,21 @@ describe('CordHost', () => { done(); }); - it('works (overwrite -> rename)', done => { + it('works (overwrite -> rename)', (done) => { const base = new TestHost({ '/hello': 'world', }); const host = new CordHost(base); - host.write(path`/hello`, fileBuffer`beautiful world`).subscribe(undefined, done.fail); + host + .write(path`/hello`, new TextEncoder().encode(`beautiful world`).buffer) + .subscribe(undefined, done.fail); host.rename(path`/hello`, path`/blue`).subscribe(undefined, done.fail); const target = base.clone(); host.commit(target).subscribe(undefined, done.fail); - expect(target.records.filter(x => mutatingTestRecord.includes(x.kind))).toEqual([ + expect(target.records.filter((x) => mutatingTestRecord.includes(x.kind))).toEqual([ { kind: 'rename', from: path`/hello`, to: path`/blue` }, { kind: 'write', path: path`/blue` }, ]); @@ -289,19 +299,21 @@ describe('CordHost', () => { done(); }); - it('works (overwrite -> delete)', done => { + it('works (overwrite -> delete)', (done) => { const base = new TestHost({ '/hello': 'world', }); const host = new CordHost(base); - host.write(path`/hello`, fileBuffer`beautiful world`).subscribe(undefined, done.fail); + host + .write(path`/hello`, new TextEncoder().encode(`beautiful world`).buffer) + .subscribe(undefined, done.fail); host.delete(path`/hello`).subscribe(undefined, done.fail); const target = base.clone(); host.commit(target).subscribe(undefined, done.fail); - expect(target.records.filter(x => mutatingTestRecord.includes(x.kind))).toEqual([ + expect(target.records.filter((x) => mutatingTestRecord.includes(x.kind))).toEqual([ { kind: 'delete', path: path`/hello` }, ]); @@ -309,19 +321,21 @@ describe('CordHost', () => { done(); }); - it('works (rename -> overwrite)', done => { + it('works (rename -> overwrite)', (done) => { const base = new TestHost({ '/hello': 'world', }); const host = new CordHost(base); host.rename(path`/hello`, path`/blue`).subscribe(undefined, done.fail); - host.write(path`/blue`, fileBuffer`beautiful world`).subscribe(undefined, done.fail); + host + .write(path`/blue`, new TextEncoder().encode(`beautiful world`).buffer) + .subscribe(undefined, done.fail); const target = base.clone(); host.commit(target).subscribe(undefined, done.fail); - expect(target.records.filter(x => mutatingTestRecord.includes(x.kind))).toEqual([ + expect(target.records.filter((x) => mutatingTestRecord.includes(x.kind))).toEqual([ { kind: 'rename', from: path`/hello`, to: path`/blue` }, { kind: 'write', path: path`/blue` }, ]); @@ -333,7 +347,7 @@ describe('CordHost', () => { done(); }); - it('works (delete)', done => { + it('works (delete)', (done) => { const base = new TestHost({ '/hello': 'world', }); @@ -344,7 +358,7 @@ describe('CordHost', () => { const target = new TestHost(); host.commit(target).subscribe(undefined, done.fail); - expect(target.records.filter(x => mutatingTestRecord.includes(x.kind))).toEqual([ + expect(target.records.filter((x) => mutatingTestRecord.includes(x.kind))).toEqual([ { kind: 'delete', path: path`/hello` }, ]); @@ -352,19 +366,21 @@ describe('CordHost', () => { done(); }); - it('works (delete -> create)', done => { + it('works (delete -> create)', (done) => { const base = new TestHost({ '/hello': 'world', }); const host = new CordHost(base); host.delete(path`/hello`).subscribe(undefined, done.fail); - host.write(path`/hello`, fileBuffer`beautiful world`).subscribe(undefined, done.fail); + host + .write(path`/hello`, new TextEncoder().encode(`beautiful world`).buffer) + .subscribe(undefined, done.fail); const target = base.clone(); host.commit(target).subscribe(undefined, done.fail); - expect(target.records.filter(x => mutatingTestRecord.includes(x.kind))).toEqual([ + expect(target.records.filter((x) => mutatingTestRecord.includes(x.kind))).toEqual([ { kind: 'write', path: path`/hello` }, ]); @@ -373,7 +389,7 @@ describe('CordHost', () => { done(); }); - it('works (rename -> delete)', done => { + it('works (rename -> delete)', (done) => { const base = new TestHost({ '/hello': 'world', }); @@ -385,7 +401,7 @@ describe('CordHost', () => { const target = base.clone(); host.commit(target).subscribe(undefined, done.fail); - expect(target.records.filter(x => mutatingTestRecord.includes(x.kind))).toEqual([ + expect(target.records.filter((x) => mutatingTestRecord.includes(x.kind))).toEqual([ { kind: 'delete', path: path`/hello` }, ]); @@ -393,7 +409,7 @@ describe('CordHost', () => { done(); }); - it('works (delete -> rename)', done => { + it('works (delete -> rename)', (done) => { const base = new TestHost({ '/hello': 'world', '/blue': 'foo', @@ -405,7 +421,7 @@ describe('CordHost', () => { const target = base.clone(); host.commit(target).subscribe(undefined, done.fail); - expect(target.records.filter(x => mutatingTestRecord.includes(x.kind))).toEqual([ + expect(target.records.filter((x) => mutatingTestRecord.includes(x.kind))).toEqual([ { kind: 'delete', path: path`/hello` }, { kind: 'write', path: path`/blue` }, ]); @@ -421,14 +437,18 @@ describe('CordHost', () => { }); const host = new CordHost(base); - host.write(path`/blue`, fileBuffer`hi`).subscribe(); + host.write(path`/blue`, new TextEncoder().encode(`hi`).buffer).subscribe(); const target = new TestHost({ '/blue': 'test', }); let error = false; - host.commit(target).subscribe(undefined, () => error = true, () => error = false); + host.commit(target).subscribe( + undefined, + () => (error = true), + () => (error = false), + ); expect(error).toBe(true); }); @@ -438,12 +458,16 @@ describe('CordHost', () => { }); const host = new CordHost(base); - host.write(path`/hello`, fileBuffer`hi`).subscribe(); + host.write(path`/hello`, new TextEncoder().encode(`hi`).buffer).subscribe(); const target = new TestHost({}); let error = false; - host.commit(target).subscribe(undefined, () => error = true, () => error = false); + host.commit(target).subscribe( + undefined, + () => (error = true), + () => (error = false), + ); expect(error).toBe(true); }); @@ -458,7 +482,11 @@ describe('CordHost', () => { const target = new TestHost({}); let error = false; - host.commit(target).subscribe(undefined, () => error = true, () => error = false); + host.commit(target).subscribe( + undefined, + () => (error = true), + () => (error = false), + ); expect(error).toBe(true); }); @@ -475,7 +503,11 @@ describe('CordHost', () => { }); let error = false; - host.commit(target).subscribe(undefined, () => error = true, () => error = false); + host.commit(target).subscribe( + undefined, + () => (error = true), + () => (error = false), + ); expect(error).toBe(true); }); @@ -486,20 +518,25 @@ describe('CordHost', () => { const host = new CordHost(base); let error = false; - host.write(path`/dir`, fileBuffer`beautiful world`) - .subscribe(undefined, () => error = true, () => error = false); + host.write(path`/dir`, new TextEncoder().encode(`beautiful world`).buffer).subscribe( + undefined, + () => (error = true), + () => (error = false), + ); expect(error).toBe(true); }); it('errors (delete: not exist)', () => { - const base = new TestHost({ - }); + const base = new TestHost({}); const host = new CordHost(base); let error = false; - host.delete(path`/hello`) - .subscribe(undefined, () => error = true, () => error = false); + host.delete(path`/hello`).subscribe( + undefined, + () => (error = true), + () => (error = false), + ); expect(error).toBe(true); }); @@ -512,20 +549,25 @@ describe('CordHost', () => { const host = new CordHost(base); let error = false; - host.rename(path`/hello`, path`/blue`) - .subscribe(undefined, () => error = true, () => error = false); + host.rename(path`/hello`, path`/blue`).subscribe( + undefined, + () => (error = true), + () => (error = false), + ); expect(error).toBe(true); }); it('errors (rename: not exist)', () => { - const base = new TestHost({ - }); + const base = new TestHost({}); const host = new CordHost(base); let error = false; - host.rename(path`/hello`, path`/blue`) - .subscribe(undefined, () => error = true, () => error = false); + host.rename(path`/hello`, path`/blue`).subscribe( + undefined, + () => (error = true), + () => (error = false), + ); expect(error).toBe(true); }); diff --git a/packages/angular_devkit/core/src/virtual-fs/host/resolver.ts b/packages/angular_devkit/core/src/virtual-fs/host/resolver.ts index 69c53d642e2f..3002290918a0 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/resolver.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/resolver.ts @@ -1,10 +1,11 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { Observable } from 'rxjs'; import { Path, PathFragment } from '../path'; import { @@ -25,7 +26,9 @@ export abstract class ResolverHost implements Host { constructor(protected _delegate: Host) {} - get capabilities(): HostCapabilities { return this._delegate.capabilities; } + get capabilities(): HostCapabilities { + return this._delegate.capabilities; + } write(path: Path, content: FileBuffer): Observable { return this._delegate.write(this._resolve(path), content); diff --git a/packages/angular_devkit/core/src/virtual-fs/host/safe.ts b/packages/angular_devkit/core/src/virtual-fs/host/safe.ts index fd4ef719261c..a260a98e2717 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/safe.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/safe.ts @@ -1,12 +1,12 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { Observable, of } from 'rxjs'; -import { catchError } from 'rxjs/operators'; + +import { Observable, catchError, of } from 'rxjs'; import { Path, PathFragment } from '../path'; import { FileBuffer, HostCapabilities, ReadonlyHost, Stats } from './interface'; @@ -26,31 +26,23 @@ export class SafeReadonlyHost implements ReadonlyHos } list(path: Path): Observable { - return this._delegate.list(path).pipe( - catchError(() => of([])), - ); + return this._delegate.list(path).pipe(catchError(() => of([]))); } exists(path: Path): Observable { return this._delegate.exists(path); } isDirectory(path: Path): Observable { - return this._delegate.isDirectory(path).pipe( - catchError(() => of(false)), - ); + return this._delegate.isDirectory(path).pipe(catchError(() => of(false))); } isFile(path: Path): Observable { - return this._delegate.isFile(path).pipe( - catchError(() => of(false)), - ); + return this._delegate.isFile(path).pipe(catchError(() => of(false))); } // Some hosts may not support stats. stat(path: Path): Observable | null> | null { const maybeStat = this._delegate.stat(path); - return maybeStat && maybeStat.pipe( - catchError(() => of(null)), - ); + return maybeStat && maybeStat.pipe(catchError(() => of(null))); } } diff --git a/packages/angular_devkit/core/src/virtual-fs/host/scoped.ts b/packages/angular_devkit/core/src/virtual-fs/host/scoped.ts index da6e884bc266..425b603a4903 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/scoped.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/scoped.ts @@ -1,16 +1,20 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { NormalizedRoot, Path, join } from '../path'; import { Host } from './interface'; import { ResolverHost } from './resolver'; export class ScopedHost extends ResolverHost { - constructor(delegate: Host, protected _root: Path = NormalizedRoot) { + constructor( + delegate: Host, + protected _root: Path = NormalizedRoot, + ) { super(delegate); } diff --git a/packages/angular_devkit/core/src/virtual-fs/host/sync.ts b/packages/angular_devkit/core/src/virtual-fs/host/sync.ts index d28097c69bf1..0e34f9c551d0 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/sync.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/sync.ts @@ -1,10 +1,11 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { Observable } from 'rxjs'; import { BaseException } from '../../exception'; import { Path, PathFragment } from '../path'; @@ -18,9 +19,10 @@ import { Stats, } from './interface'; - export class SynchronousDelegateExpectedException extends BaseException { - constructor() { super(`Expected a synchronous delegate but got an asynchronous one.`); } + constructor() { + super(`Expected a synchronous delegate but got an asynchronous one.`); + } } /** @@ -37,11 +39,13 @@ export class SyncDelegateHost { let completed = false; let result: ResultT | undefined = undefined; let errorResult: Error | undefined = undefined; - observable.subscribe({ - next(x: ResultT) { result = x; }, - error(err: Error) { errorResult = err; }, - complete() { completed = true; }, - }); + // Perf note: this is not using an observer object to avoid a performance penalty in RxJS. + // See https://github.com/ReactiveX/rxjs/pull/5646 for details. + observable.subscribe( + (x: ResultT) => (result = x), + (err: Error) => (errorResult = err), + () => (completed = true), + ); if (errorResult !== undefined) { throw errorResult; @@ -53,14 +57,14 @@ export class SyncDelegateHost { // The non-null operation is to work around `void` type. We don't allow to return undefined // but ResultT could be void, which is undefined in JavaScript, so this doesn't change the // behaviour. - // tslint:disable-next-line:no-non-null-assertion - return result !; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return result!; } get capabilities(): HostCapabilities { return this._delegate.capabilities; } - get delegate() { + get delegate(): Host { return this._delegate; } diff --git a/packages/angular_devkit/core/src/virtual-fs/host/test.ts b/packages/angular_devkit/core/src/virtual-fs/host/test.ts index 4efdae796f4b..4e7b083d8bdc 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/test.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/test.ts @@ -1,53 +1,62 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { Observable } from 'rxjs'; import { Path, PathFragment, join, normalize } from '../path'; -import { fileBufferToString, stringToFileBuffer } from './buffer'; import { FileBuffer, HostWatchEvent, HostWatchOptions, Stats } from './interface'; import { SimpleMemoryHost, SimpleMemoryHostStats } from './memory'; import { SyncDelegateHost } from './sync'; - -export type TestLogRecord = { - kind: 'write' | 'read' | 'delete' | 'list' | 'exists' | 'isDirectory' | 'isFile' | 'stat' +export type TestLogRecord = + | { + kind: + | 'write' + | 'read' + | 'delete' + | 'list' + | 'exists' + | 'isDirectory' + | 'isFile' + | 'stat' | 'watch'; - path: Path; - } | { - kind: 'rename'; - from: Path; - to: Path; - }; - + path: Path; + } + | { + kind: 'rename'; + from: Path; + to: Path; + }; export class TestHost extends SimpleMemoryHost { protected _records: TestLogRecord[] = []; - protected _sync: SyncDelegateHost<{}>; + protected _sync: SyncDelegateHost<{}> | null = null; constructor(map: { [path: string]: string } = {}) { super(); for (const filePath of Object.getOwnPropertyNames(map)) { - this._write(normalize(filePath), stringToFileBuffer(map[filePath])); + this._write(normalize(filePath), new TextEncoder().encode(map[filePath]).buffer); } } get records(): TestLogRecord[] { return [...this._records]; } - clearRecords() { + clearRecords(): void { this._records = []; } get files(): Path[] { const sync = this.sync; function _visit(p: Path): Path[] { - return sync.list(p) - .map(fragment => join(p, fragment)) + return sync + .list(p) + .map((fragment) => join(p, fragment)) .reduce((files, path) => { if (sync.isDirectory(path)) { return files.concat(_visit(path)); @@ -60,7 +69,7 @@ export class TestHost extends SimpleMemoryHost { return _visit(normalize('/')); } - get sync() { + get sync(): SyncDelegateHost<{}> { if (!this._sync) { this._sync = new SyncDelegateHost<{}>(this); } @@ -68,7 +77,7 @@ export class TestHost extends SimpleMemoryHost { return this._sync; } - clone() { + clone(): TestHost { const newHost = new TestHost(); newHost._cache = new Map(this._cache); @@ -76,78 +85,78 @@ export class TestHost extends SimpleMemoryHost { } // Override parents functions to keep a record of all operators that were done. - protected _write(path: Path, content: FileBuffer) { + protected override _write(path: Path, content: FileBuffer): void { this._records.push({ kind: 'write', path }); return super._write(path, content); } - protected _read(path: Path) { + protected override _read(path: Path): ArrayBuffer { this._records.push({ kind: 'read', path }); return super._read(path); } - protected _delete(path: Path) { + protected override _delete(path: Path): void { this._records.push({ kind: 'delete', path }); return super._delete(path); } - protected _rename(from: Path, to: Path) { + protected override _rename(from: Path, to: Path): void { this._records.push({ kind: 'rename', from, to }); return super._rename(from, to); } - protected _list(path: Path): PathFragment[] { + protected override _list(path: Path): PathFragment[] { this._records.push({ kind: 'list', path }); return super._list(path); } - protected _exists(path: Path) { + protected override _exists(path: Path): boolean { this._records.push({ kind: 'exists', path }); return super._exists(path); } - protected _isDirectory(path: Path) { + protected override _isDirectory(path: Path): boolean { this._records.push({ kind: 'isDirectory', path }); return super._isDirectory(path); } - protected _isFile(path: Path) { + protected override _isFile(path: Path): boolean { this._records.push({ kind: 'isFile', path }); return super._isFile(path); } - protected _stat(path: Path): Stats | null { + protected override _stat(path: Path): Stats | null { this._records.push({ kind: 'stat', path }); return super._stat(path); } - protected _watch(path: Path, options?: HostWatchOptions): Observable { + protected override _watch(path: Path, options?: HostWatchOptions): Observable { this._records.push({ kind: 'watch', path }); return super._watch(path, options); } - $write(path: string, content: string) { - return super._write(normalize(path), stringToFileBuffer(content)); + $write(path: string, content: string): void { + return super._write(normalize(path), new TextEncoder().encode(content).buffer); } $read(path: string): string { - return fileBufferToString(super._read(normalize(path))); + return new TextDecoder().decode(super._read(normalize(path))); } $list(path: string): PathFragment[] { return super._list(normalize(path)); } - $exists(path: string) { + $exists(path: string): boolean { return super._exists(normalize(path)); } - $isDirectory(path: string) { + $isDirectory(path: string): boolean { return super._isDirectory(normalize(path)); } - $isFile(path: string) { + $isFile(path: string): boolean { return super._isFile(normalize(path)); } } diff --git a/packages/angular_devkit/core/src/virtual-fs/host/test_spec.ts b/packages/angular_devkit/core/src/virtual-fs/host/test_spec.ts index dc8556054ab6..044db5725729 100644 --- a/packages/angular_devkit/core/src/virtual-fs/host/test_spec.ts +++ b/packages/angular_devkit/core/src/virtual-fs/host/test_spec.ts @@ -1,16 +1,15 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { TestHost } from './test'; +import * as test from './test'; // Yes, we realize the irony of testing a test host. describe('TestHost', () => { - it('can list files', () => { const files = { '/x/y/z': '', @@ -19,8 +18,7 @@ describe('TestHost', () => { '/x/y/b': '', }; - const host = new TestHost(files); + const host = new test.TestHost(files); expect(host.files.sort() as string[]).toEqual(Object.keys(files).sort()); }); - }); diff --git a/packages/angular_devkit/core/src/virtual-fs/index.ts b/packages/angular_devkit/core/src/virtual-fs/index.ts index ab9ccf25fcd1..e062418e6b54 100644 --- a/packages/angular_devkit/core/src/virtual-fs/index.ts +++ b/packages/angular_devkit/core/src/virtual-fs/index.ts @@ -1,11 +1,12 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -export * from './path'; -import * as virtualFs from './host'; +import * as virtualFs from './host/index'; + +export * from './path'; export { virtualFs }; diff --git a/packages/angular_devkit/core/src/virtual-fs/path.ts b/packages/angular_devkit/core/src/virtual-fs/path.ts index 0fd5eb374116..783bfe9d4402 100644 --- a/packages/angular_devkit/core/src/virtual-fs/path.ts +++ b/packages/angular_devkit/core/src/virtual-fs/path.ts @@ -1,25 +1,30 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { BaseException } from '../exception'; import { TemplateTag } from '../utils/literals'; - export class InvalidPathException extends BaseException { - constructor(path: string) { super(`Path ${JSON.stringify(path)} is invalid.`); } + constructor(path: string) { + super(`Path ${JSON.stringify(path)} is invalid.`); + } } export class PathMustBeAbsoluteException extends BaseException { - constructor(path: string) { super(`Path ${JSON.stringify(path)} must be absolute.`); } + constructor(path: string) { + super(`Path ${JSON.stringify(path)} must be absolute.`); + } } export class PathCannotBeFragmentException extends BaseException { - constructor(path: string) { super(`Path ${JSON.stringify(path)} cannot be made a fragment.`); } + constructor(path: string) { + super(`Path ${JSON.stringify(path)} cannot be made a fragment.`); + } } - /** * A Path recognized by most methods in the DevKit. */ @@ -34,20 +39,15 @@ export type PathFragment = Path & { __PRIVATE_DEVKIT_PATH_FRAGMENT: void; }; - /** * The Separator for normalized path. - * @type {Path} */ -export const NormalizedSep = '/' as Path; - +export const NormalizedSep: Path = '/' as Path; /** * The root of a normalized path. - * @type {Path} */ -export const NormalizedRoot = NormalizedSep as Path; - +export const NormalizedRoot: Path = NormalizedSep; /** * Split a path into multiple path fragments. Each fragments except the last one will end with @@ -56,8 +56,8 @@ export const NormalizedRoot = NormalizedSep as Path; * @returns {Path[]} An array of path fragments. */ export function split(path: Path): PathFragment[] { - const fragments = path.split(NormalizedSep).map(x => fragment(x)); - if (fragments[fragments.length - 1].length === 0) { + const fragments = path.split(NormalizedSep).map((x) => fragment(x)); + if (fragments.at(-1)?.length === 0) { fragments.pop(); } @@ -73,11 +73,10 @@ export function extname(path: Path): string { if (i < 1) { return ''; } else { - return base.substr(i); + return base.slice(i); } } - /** * Return the basename of the path, as a Path. See path.basename */ @@ -86,11 +85,10 @@ export function basename(path: Path): PathFragment { if (i == -1) { return fragment(path); } else { - return fragment(path.substr(path.lastIndexOf(NormalizedSep) + 1)); + return fragment(path.slice(path.lastIndexOf(NormalizedSep) + 1)); } } - /** * Return the dirname of the path, as a Path. See path.dirname */ @@ -102,10 +100,9 @@ export function dirname(path: Path): Path { const endIndex = index === 0 ? 1 : index; // case of file under root: '/file' - return normalize(path.substr(0, endIndex)); + return normalize(path.slice(0, endIndex)); } - /** * Join multiple paths together, and normalize the result. Accepts strings that will be * normalized as well (but the original must be a path). @@ -118,15 +115,13 @@ export function join(p1: Path, ...others: string[]): Path { } } - /** * Returns true if a path is absolute. */ -export function isAbsolute(p: Path) { +export function isAbsolute(p: Path): boolean { return p.startsWith(NormalizedSep); } - /** * Returns a path such that `join(from, relative(from, to)) == to`. * Both paths must be absolute, otherwise it does not make much sense. @@ -144,8 +139,8 @@ export function relative(from: Path, to: Path): Path { if (from == to) { p = ''; } else { - const splitFrom = from.split(NormalizedSep); - const splitTo = to.split(NormalizedSep); + const splitFrom = split(from); + const splitTo = split(to); while (splitFrom.length > 0 && splitTo.length > 0 && splitFrom[0] == splitTo[0]) { splitFrom.shift(); @@ -155,19 +150,21 @@ export function relative(from: Path, to: Path): Path { if (splitFrom.length == 0) { p = splitTo.join(NormalizedSep); } else { - p = splitFrom.map(_ => '..').concat(splitTo).join(NormalizedSep); + p = splitFrom + .map(() => '..') + .concat(splitTo) + .join(NormalizedSep); } } return normalize(p); } - /** * Returns a Path that is the resolution of p2, from p1. If p2 is absolute, it will return p2, * otherwise will join both p1 and p2. */ -export function resolve(p1: Path, p2: Path) { +export function resolve(p1: Path, p2: Path): Path { if (isAbsolute(p2)) { return p2; } else { @@ -175,7 +172,6 @@ export function resolve(p1: Path, p2: Path) { } } - export function fragment(path: string): PathFragment { if (path.indexOf(NormalizedSep) != -1) { throw new PathCannotBeFragmentException(path); @@ -184,23 +180,20 @@ export function fragment(path: string): PathFragment { return path as PathFragment; } - /** * normalize() cache to reduce computation. For now this grows and we never flush it, but in the * future we might want to add a few cache flush to prevent this from growing too large. */ let normalizedCache = new Map(); - /** * Reset the cache. This is only useful for testing. * @private */ -export function resetNormalizeCache() { +export function resetNormalizeCache(): void { normalizedCache = new Map(); } - /** * Normalize a string into a Path. This is the only mean to get a Path type from a string that * represents a system path. This method cache the results as real world paths tend to be @@ -225,7 +218,6 @@ export function normalize(path: string): Path { return maybePath; } - /** * The no cache version of the normalize() function. Used for benchmarking and testing. */ @@ -238,12 +230,12 @@ export function noCacheNormalize(path: string): Path { // Match absolute windows path. const original = path; - if (path.match(/^[A-Z]:[\/\\]/i)) { - path = '\\' + path[0] + '\\' + path.substr(3); + if (path.match(/^[A-Z]:[/\\]/i)) { + path = '\\' + path[0].toUpperCase() + '\\' + path.slice(3); } // We convert Windows paths as well here. - const p = path.split(/[\/\\]/g); + const p = path.split(/[/\\]/g); let relative = false; let i = 1; @@ -273,7 +265,7 @@ export function noCacheNormalize(path: string): Path { } if (p.length == 1) { - return p[0] == '' ? NormalizedSep : '' as Path; + return p[0] == '' ? NormalizedSep : ('' as Path); } else { if (p[0] == '.') { p.shift(); @@ -283,12 +275,10 @@ export function noCacheNormalize(path: string): Path { } } - export const path: TemplateTag = (strings, ...values) => { return normalize(String.raw(strings, ...values)); }; - // Platform-specific paths. export type WindowsPath = string & { __PRIVATE_DEVKIT_WINDOWS_PATH: void; @@ -298,9 +288,11 @@ export type PosixPath = string & { }; export function asWindowsPath(path: Path): WindowsPath { - const drive = path.match(/^\/(\w)\/(.*)$/); + const drive = path.match(/^\/(\w)(?:\/(.*))?$/); if (drive) { - return `${drive[1]}:\\${drive[2].replace(/\//g, '\\')}` as WindowsPath; + const subPath = drive[2] ? drive[2].replace(/\//g, '\\') : ''; + + return `${drive[1]}:\\${subPath}` as WindowsPath; } return path.replace(/\//g, '\\') as WindowsPath; diff --git a/packages/angular_devkit/core/src/virtual-fs/path_benchmark.ts b/packages/angular_devkit/core/src/virtual-fs/path_benchmark.ts deleted file mode 100644 index 9973c625bae7..000000000000 --- a/packages/angular_devkit/core/src/virtual-fs/path_benchmark.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable:no-implicit-dependencies -import { benchmark } from '@_/benchmark'; -import { join, noCacheNormalize, normalize, resetNormalizeCache } from './path'; - -const seedrandom = require('seedrandom'); - - -const p1 = '/b/././a/tt/../../../a/b/./d/../c'; -const p2 = '/a/tt/../../../a/b/./d'; - - -const numRandomIter = 10000; - - -describe('Virtual FS Path', () => { - benchmark('join', () => join(normalize(p1), p2)); - - describe('normalize', () => { - let rng: () => number; - let cases: string[]; - - // Math.random() doesn't allow us to set a seed, so we use a library. - beforeEach(() => { - rng = seedrandom('some fixed value'); - - function _str(len: number) { - let r = ''; - const space = 'abcdefghijklmnopqrstuvwxyz0123456789'; - for (let i = 0; i < len; i++) { - r += space[Math.floor(rng() * space.length)]; - } - - return r; - } - - // Build test cases. - cases = new Array(numRandomIter) - .fill(0) - .map(() => { - return new Array(Math.floor(rng() * 20 + 5)) - .fill(0) - .map(() => _str(rng() * 20 + 3)) - .join('/'); - }); - - resetNormalizeCache(); - }); - - describe('random (0 cache hits)', () => { - benchmark('', i => normalize(cases[i]), i => noCacheNormalize(cases[i])); - }); - - describe('random (10% cache hits)', () => { - beforeEach(() => { - cases = cases.map(x => (rng() < 0.1) ? cases[0] : x); - }); - benchmark('', i => normalize(cases[i]), i => noCacheNormalize(cases[i])); - }); - - describe('random (30% cache hits)', () => { - beforeEach(() => { - cases = cases.map(x => (rng() < 0.3) ? cases[0] : x); - }); - benchmark('', i => normalize(cases[i]), i => noCacheNormalize(cases[i])); - }); - - describe('random (50% cache hits)', () => { - beforeEach(() => { - cases = cases.map(x => (rng() < 0.5) ? cases[0] : x); - }); - benchmark('', i => normalize(cases[i]), i => noCacheNormalize(cases[i])); - }); - - describe('random (80% cache hits)', () => { - beforeEach(() => { - cases = cases.map(x => (rng() < 0.8) ? cases[0] : x); - }); - benchmark('', i => normalize(cases[i]), i => noCacheNormalize(cases[i])); - }); - }); -}); diff --git a/packages/angular_devkit/core/src/virtual-fs/path_spec.ts b/packages/angular_devkit/core/src/virtual-fs/path_spec.ts index fa63df2e7412..e5da5e66e838 100644 --- a/packages/angular_devkit/core/src/virtual-fs/path_spec.ts +++ b/packages/angular_devkit/core/src/virtual-fs/path_spec.ts @@ -1,13 +1,16 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { InvalidPathException, Path, + PathFragment, + asWindowsPath, basename, dirname, join, @@ -16,7 +19,6 @@ import { split, } from './path'; - describe('path', () => { it('normalize', () => { expect(normalize('////')).toBe('/'); @@ -56,12 +58,13 @@ describe('path', () => { expect(normalize('./a/../../a/b/c')).toBe('../a/b/c'); // Invalid use cases. - expect(() => normalize('/./././../././/')) - .toThrow(new InvalidPathException('/./././../././/')); - expect(() => normalize('/./././../././/../')) - .toThrow(new InvalidPathException('/./././../././/../')); - expect(() => normalize('/./././../././a/.')) - .toThrow(new InvalidPathException('/./././../././a/.')); + expect(() => normalize('/./././../././/')).toThrow(new InvalidPathException('/./././../././/')); + expect(() => normalize('/./././../././/../')).toThrow( + new InvalidPathException('/./././../././/../'), + ); + expect(() => normalize('/./././../././a/.')).toThrow( + new InvalidPathException('/./././../././a/.'), + ); expect(() => normalize('/c/../../')).toThrow(new InvalidPathException('/c/../../')); @@ -70,10 +73,9 @@ describe('path', () => { expect(normalize('\\a\\b\\c')).toBe('/a/b/c'); expect(normalize('.\\a\\b\\c')).toBe('a/b/c'); expect(normalize('C:\\a\\b\\c')).toBe('/C/a/b/c'); - expect(normalize('c:\\a\\b\\c')).toBe('/c/a/b/c'); + expect(normalize('c:\\a\\b\\c')).toBe('/C/a/b/c'); expect(normalize('A:\\a\\b\\c')).toBe('/A/a/b/c'); - expect(() => normalize('A:\\..\\..')) - .toThrow(new InvalidPathException('A:\\..\\..')); + expect(() => normalize('A:\\..\\..')).toThrow(new InvalidPathException('A:\\..\\..')); expect(normalize('\\.\\a\\b\\c')).toBe('/a/b/c'); expect(normalize('\\.\\a\\b\\.\\c')).toBe('/a/b/c'); expect(normalize('\\.\\a\\b\\d\\..\\c')).toBe('/a/b/c'); @@ -82,7 +84,7 @@ describe('path', () => { }); describe('split', () => { - const tests = [ + const tests: [string, string[]][] = [ ['a', ['a']], ['/a/b', ['', 'a', 'b']], ['a/b', ['a', 'b']], @@ -91,31 +93,29 @@ describe('path', () => { ['/', ['']], ]; - for (const goldens of tests) { - const result = goldens.pop(); - const args = goldens.map((x: string) => normalize(x)) as Path[]; + for (const [input, result] of tests) { + it(`(${input}) == "${result}"`, () => { + const normalizedInput = normalize(input); - it(`(${JSON.stringify(args)}) == "${result}"`, () => { - expect(split.apply(null, args)).toEqual(result); + expect(split(normalizedInput)).toEqual(result as PathFragment[]); }); } }); describe('join', () => { - const tests = [ - ['a', 'a'], - ['/a', '/b', '/a/b'], - ['/a', '/b', '/c', '/a/b/c'], - ['/a', 'b', 'c', '/a/b/c'], - ['a', 'b', 'c', 'a/b/c'], + const tests: [string[], string][] = [ + [['a'], 'a'], + [['/a', '/b'], '/a/b'], + [['/a', '/b', '/c'], '/a/b/c'], + [['/a', 'b', 'c'], '/a/b/c'], + [['a', 'b', 'c'], 'a/b/c'], ]; - for (const goldens of tests) { - const result = goldens.pop(); - const args = goldens.map(x => normalize(x)) as Path[]; + for (const [input, result] of tests) { + const args = input.map((x) => normalize(x)) as [Path, ...Path[]]; it(`(${JSON.stringify(args)}) == "${result}"`, () => { - expect(join.apply(null, args)).toBe(result); + expect(join(...args)).toBe(result); }); } }); @@ -128,10 +128,10 @@ describe('path', () => { ['/a/b/c', '/a/b', '..'], ['/a/b/c', '/a/b/d', '../d'], ['/a/b/c/d/e', '/a/f/g', '../../../../f/g'], - [ - '/src/app/sub1/test1', '/src/app/sub2/test2', - '../../sub2/test2', - ], + ['/src/app/sub1/test1', '/src/app/sub2/test2', '../../sub2/test2'], + ['/', '/a/b/c', 'a/b/c'], + ['/a/b/c', '/d', '../../../d'], + ['E:\\abc', 'e:\\abc\\def', 'def'], ]; for (const [from, to, result] of tests) { @@ -140,6 +140,7 @@ describe('path', () => { const t = normalize(to); expect(relative(f, t)).toBe(result); + expect(join(f, relative(f, t))).toBe(t); }); } }); @@ -159,4 +160,10 @@ describe('path', () => { expect(basename(normalize('.'))).toBe(''); expect(basename(normalize('./a/b/c'))).toBe('c'); }); + + it('asWindowsPath', () => { + expect(asWindowsPath(normalize('c:/'))).toBe('C:\\'); + expect(asWindowsPath(normalize('c:/b/'))).toBe('C:\\b'); + expect(asWindowsPath(normalize('c:/b/c'))).toBe('C:\\b\\c'); + }); }); diff --git a/packages/angular_devkit/core/src/workspace/core.ts b/packages/angular_devkit/core/src/workspace/core.ts new file mode 100644 index 000000000000..261781ba05fa --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/core.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { basename, getSystemPath, join, normalize } from '../virtual-fs'; +import { WorkspaceDefinition } from './definitions'; +import { WorkspaceHost } from './host'; +import { readJsonWorkspace } from './json/reader'; +import { writeJsonWorkspace } from './json/writer'; + +const formatLookup = new WeakMap(); + +/** + * Supported workspace formats + */ +export enum WorkspaceFormat { + JSON, +} + +/** + * @private + */ +export function _test_addWorkspaceFile(name: string, format: WorkspaceFormat): void { + workspaceFiles[name] = format; +} + +/** + * @private + */ +export function _test_removeWorkspaceFile(name: string): void { + delete workspaceFiles[name]; +} + +// NOTE: future additions could also perform content analysis to determine format/version +const workspaceFiles: Record = { + 'angular.json': WorkspaceFormat.JSON, + '.angular.json': WorkspaceFormat.JSON, +}; + +/** + * Reads and constructs a `WorkspaceDefinition`. If the function is provided with a path to a + * directory instead of a file, a search of the directory's files will commence to attempt to + * locate a known workspace file. Currently the following are considered known workspace files: + * - `angular.json` + * - `.angular.json` + * + * @param path The path to either a workspace file or a directory containing a workspace file. + * @param host The `WorkspaceHost` to use to access the file and directory data. + * @param format An optional `WorkspaceFormat` value. Used if the path specifies a non-standard + * file name that would prevent automatically discovering the format. + * + * + * @return An `Promise` of the read result object with the `WorkspaceDefinition` contained within + * the `workspace` property. + */ +export async function readWorkspace( + path: string, + host: WorkspaceHost, + format?: WorkspaceFormat, + // return type will eventually have a `diagnostics` property as well +): Promise<{ workspace: WorkspaceDefinition }> { + if (await host.isDirectory(path)) { + // TODO: Warn if multiple found (requires diagnostics support) + const directory = normalize(path); + let found = false; + for (const [name, nameFormat] of Object.entries(workspaceFiles)) { + if (format !== undefined && format !== nameFormat) { + continue; + } + + const potential = getSystemPath(join(directory, name)); + if (await host.isFile(potential)) { + path = potential; + format = nameFormat; + found = true; + break; + } + } + if (!found) { + throw new Error( + 'Unable to locate a workspace file for workspace path. Are you missing an `angular.json`' + + ' or `.angular.json` file?', + ); + } + } else if (format === undefined) { + const filename = basename(normalize(path)); + if (filename in workspaceFiles) { + format = workspaceFiles[filename]; + } + } + + if (format === undefined) { + throw new Error('Unable to determine format for workspace path.'); + } + + let workspace; + switch (format) { + case WorkspaceFormat.JSON: + workspace = await readJsonWorkspace(path, host); + break; + default: + throw new Error('Unsupported workspace format.'); + } + + formatLookup.set(workspace, WorkspaceFormat.JSON); + + return { workspace }; +} + +/** + * Writes a `WorkspaceDefinition` to the underlying storage via the provided `WorkspaceHost`. + * If the `WorkspaceDefinition` was created via the `readWorkspace` function, metadata will be + * used to determine the path and format of the Workspace. In all other cases, the `path` and + * `format` options must be specified as they would be otherwise unknown. + * + * @param workspace The `WorkspaceDefinition` that will be written. + * @param host The `WorkspaceHost` to use to access/write the file and directory data. + * @param path The path to a file location for the output. Required if `readWorkspace` was not + * used to create the `WorkspaceDefinition`. Optional otherwise; will override the + * `WorkspaceDefinition` metadata if provided. + * @param format The `WorkspaceFormat` to use for output. Required if `readWorkspace` was not + * used to create the `WorkspaceDefinition`. Optional otherwise; will override the + * `WorkspaceDefinition` metadata if provided. + * + * + * @return An `Promise` of type `void`. + */ +export async function writeWorkspace( + workspace: WorkspaceDefinition, + host: WorkspaceHost, + path?: string, + format?: WorkspaceFormat, +): Promise { + if (format === undefined) { + format = formatLookup.get(workspace); + if (format === undefined) { + throw new Error('A format is required for custom workspace objects.'); + } + } + + switch (format) { + case WorkspaceFormat.JSON: + return writeJsonWorkspace(workspace, host, path); + default: + throw new Error('Unsupported workspace format.'); + } +} diff --git a/packages/angular_devkit/core/src/workspace/core_spec.ts b/packages/angular_devkit/core/src/workspace/core_spec.ts new file mode 100644 index 000000000000..5f953e98c912 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/core_spec.ts @@ -0,0 +1,351 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use 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 @typescript-eslint/no-empty-function */ +import { getSystemPath, join, normalize } from '../virtual-fs'; +import { + WorkspaceFormat, + _test_addWorkspaceFile, + _test_removeWorkspaceFile, + readWorkspace, + writeWorkspace, +} from './core'; +import { WorkspaceDefinition } from './definitions'; +import { WorkspaceHost } from './host'; + +describe('readWorkspace', () => { + it('attempts to read from specified file path [angular.json]', async () => { + const requestedPath = '/path/to/workspace/angular.json'; + + const host: WorkspaceHost = { + async readFile(path) { + expect(path).toBe(requestedPath); + + return '{ "version": 1 }'; + }, + async writeFile() {}, + async isFile(path) { + return path === requestedPath; + }, + async isDirectory(path) { + if (path !== requestedPath) { + fail(); + } + + return false; + }, + }; + + await readWorkspace(requestedPath, host); + }); + + it('attempts to read from specified file path [.angular.json]', async () => { + const requestedPath = '/path/to/workspace/.angular.json'; + + const host: WorkspaceHost = { + async readFile(path) { + expect(path).toBe(requestedPath); + + return '{ "version": 1 }'; + }, + async writeFile() {}, + async isFile(path) { + return path === requestedPath; + }, + async isDirectory(path) { + if (path !== requestedPath) { + fail(); + } + + return false; + }, + }; + + await readWorkspace(requestedPath, host); + }); + + it('attempts to read from specified non-standard file path with format', async () => { + const requestedPath = '/path/to/workspace/abc.json'; + + const host: WorkspaceHost = { + async readFile(path) { + expect(path).toBe(requestedPath); + + return '{ "version": 1 }'; + }, + async writeFile() {}, + async isFile(path) { + return path === requestedPath; + }, + async isDirectory(path) { + if (path !== requestedPath) { + fail(); + } + + return false; + }, + }; + + await readWorkspace(requestedPath, host, WorkspaceFormat.JSON); + }); + + it('errors when reading from specified non-standard file path without format', async () => { + const requestedPath = '/path/to/workspace/abc.json'; + + const host: WorkspaceHost = { + async readFile(path) { + expect(path).toBe(requestedPath); + + return '{ "version": 1 }'; + }, + async writeFile() {}, + async isFile(path) { + return path === requestedPath; + }, + async isDirectory(path) { + if (path !== requestedPath) { + fail(); + } + + return false; + }, + }; + + await expectAsync(readWorkspace(requestedPath, host)).toBeRejectedWithError( + /Unable to determine format for workspace path/, + ); + }); + + it('errors when reading from specified file path with invalid specified format', async () => { + const requestedPath = '/path/to/workspace/angular.json'; + + const host: WorkspaceHost = { + async readFile(path) { + expect(path).toBe(requestedPath); + + return '{ "version": 1 }'; + }, + async writeFile() {}, + async isFile(path) { + return path === requestedPath; + }, + async isDirectory(path) { + if (path !== requestedPath) { + fail(); + } + + return false; + }, + }; + + await expectAsync( + readWorkspace(requestedPath, host, 12 as WorkspaceFormat), + ).toBeRejectedWithError(/Unsupported workspace format/); + }); + + it('attempts to find/read from directory path', async () => { + const requestedPath = getSystemPath(normalize('/path/to/workspace')); + const expectedFile = getSystemPath(join(normalize(requestedPath), '.angular.json')); + + const isFileChecks: string[] = []; + const host: WorkspaceHost = { + async readFile(path) { + expect(path).not.toBe(requestedPath); + expect(path).toBe(expectedFile); + + return '{ "version": 1 }'; + }, + async writeFile() {}, + async isFile(path) { + isFileChecks.push(path); + + return path === expectedFile; + }, + async isDirectory(path) { + if (path === requestedPath) { + return true; + } + + fail(); + + return false; + }, + }; + + await readWorkspace(requestedPath, host); + isFileChecks.sort(); + expect(isFileChecks).toEqual( + [ + getSystemPath(join(normalize(requestedPath), 'angular.json')), + getSystemPath(join(normalize(requestedPath), '.angular.json')), + ].sort(), + ); + }); + + it('attempts to find/read only files for specified format from directory path', async () => { + const requestedPath = '/path/to/workspace'; + + const isFileChecks: string[] = []; + const readFileChecks: string[] = []; + const host: WorkspaceHost = { + async readFile(path) { + expect(path).not.toBe(requestedPath); + readFileChecks.push(path); + + return '{ "version": 1 }'; + }, + async writeFile() {}, + async isFile(path) { + isFileChecks.push(path); + + return true; + }, + async isDirectory(path) { + if (path === requestedPath) { + return true; + } + + fail(); + + return false; + }, + }; + + _test_addWorkspaceFile('wrong.format', 99 as WorkspaceFormat); + try { + await readWorkspace(requestedPath, host, WorkspaceFormat.JSON); + } finally { + _test_removeWorkspaceFile('wrong.format'); + } + + isFileChecks.sort(); + expect(isFileChecks).toEqual([getSystemPath(join(normalize(requestedPath), 'angular.json'))]); + + readFileChecks.sort(); + expect(readFileChecks).toEqual([getSystemPath(join(normalize(requestedPath), 'angular.json'))]); + }); + + it('errors when no file found from specified directory path', async () => { + const requestedPath = '/path/to/workspace'; + + const host: WorkspaceHost = { + async readFile(path) { + expect(path).not.toBe(requestedPath); + + return '{ "version": 1 }'; + }, + async writeFile() {}, + async isFile() { + return false; + }, + async isDirectory(path) { + if (path === requestedPath) { + return true; + } + + fail(); + + return false; + }, + }; + + await expectAsync(readWorkspace(requestedPath, host)).toBeRejectedWithError( + /Unable to locate a workspace file/, + ); + }); +}); + +describe('writeWorkspace', () => { + it('attempts to write to specified file path', async () => { + const requestedPath = '/path/to/workspace/angular.json'; + + let writtenPath: string | undefined; + const host: WorkspaceHost = { + async readFile() { + fail(); + + return ''; + }, + async writeFile(path) { + expect(writtenPath).toBeUndefined(); + writtenPath = path; + }, + async isFile() { + fail(); + + return false; + }, + async isDirectory() { + fail(); + + return false; + }, + }; + + await writeWorkspace({} as WorkspaceDefinition, host, requestedPath, WorkspaceFormat.JSON); + expect(writtenPath).toBe(requestedPath); + }); + + it('errors when writing to specified file path with invalid specified format', async () => { + const requestedPath = '/path/to/workspace/angular.json'; + + const host: WorkspaceHost = { + async readFile() { + fail(); + + return ''; + }, + async writeFile() { + fail(); + }, + async isFile() { + fail(); + + return false; + }, + async isDirectory() { + fail(); + + return false; + }, + }; + + await expectAsync( + writeWorkspace({} as WorkspaceDefinition, host, requestedPath, 12 as WorkspaceFormat), + ).toBeRejectedWithError(/Unsupported workspace format/); + }); + + it('errors when writing custom workspace without specified format', async () => { + const requestedPath = '/path/to/workspace/angular.json'; + + const host: WorkspaceHost = { + async readFile() { + fail(); + + return ''; + }, + async writeFile() { + fail(); + }, + async isFile() { + fail(); + + return false; + }, + async isDirectory() { + fail(); + + return false; + }, + }; + + await expectAsync( + writeWorkspace({} as WorkspaceDefinition, host, requestedPath), + ).toBeRejectedWithError(/A format is required/); + }); +}); diff --git a/packages/angular_devkit/core/src/workspace/definitions.ts b/packages/angular_devkit/core/src/workspace/definitions.ts new file mode 100644 index 000000000000..dc3bf4ab4608 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/definitions.ts @@ -0,0 +1,251 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { JsonValue } from '../json'; + +export interface WorkspaceDefinition { + readonly extensions: Record; + readonly projects: ProjectDefinitionCollection; +} + +export interface ProjectDefinition { + readonly extensions: Record; + readonly targets: TargetDefinitionCollection; + + root: string; + prefix?: string; + sourceRoot?: string; +} + +export interface TargetDefinition { + options?: Record; + configurations?: Record | undefined>; + defaultConfiguration?: string; + builder: string; +} + +export type DefinitionCollectionListener = ( + name: string, + newValue: V | undefined, + collection: DefinitionCollection, +) => void; + +class DefinitionCollection implements ReadonlyMap { + private _map: Map; + + constructor( + initial?: Record, + private _listener?: DefinitionCollectionListener, + ) { + this._map = new Map(initial && Object.entries(initial)); + } + + delete(key: string): boolean { + const result = this._map.delete(key); + + if (result) { + this._listener?.(key, undefined, this); + } + + return result; + } + + set(key: string, value: V): this { + const updatedValue = value !== this.get(key); + + if (updatedValue) { + this._map.set(key, value); + this._listener?.(key, value, this); + } + + return this; + } + + forEach( + callbackfn: (value: V, key: string, map: DefinitionCollection) => void, + thisArg?: T, + ): void { + this._map.forEach((value, key) => callbackfn(value, key, this), thisArg); + } + + get(key: string): V | undefined { + return this._map.get(key); + } + + has(key: string): boolean { + return this._map.has(key); + } + + get size(): number { + return this._map.size; + } + + [Symbol.iterator](): MapIterator<[string, V]> { + return this._map[Symbol.iterator](); + } + + entries(): MapIterator<[string, V]> { + return this._map.entries(); + } + + keys(): MapIterator { + return this._map.keys(); + } + + values(): MapIterator { + return this._map.values(); + } +} + +function isJsonValue(value: unknown): value is JsonValue { + const visited = new Set(); + + switch (typeof value) { + case 'boolean': + case 'number': + case 'string': + return true; + case 'object': + if (value === null) { + return true; + } + visited.add(value); + for (const property of Object.values(value)) { + if (typeof value === 'object' && visited.has(property)) { + continue; + } + if (!isJsonValue(property)) { + return false; + } + } + + return true; + default: + return false; + } +} + +export class ProjectDefinitionCollection extends DefinitionCollection { + constructor( + initial?: Record, + listener?: DefinitionCollectionListener, + ) { + super(initial, listener); + } + + add(definition: { + name: string; + root: string; + sourceRoot?: string; + prefix?: string; + targets?: Record; + [key: string]: unknown; + }): ProjectDefinition { + if (this.has(definition.name)) { + throw new Error('Project name already exists.'); + } + this._validateName(definition.name); + + const project: ProjectDefinition = { + root: definition.root, + prefix: definition.prefix, + sourceRoot: definition.sourceRoot, + targets: new TargetDefinitionCollection(), + extensions: {}, + }; + + if (definition.targets) { + for (const [name, target] of Object.entries(definition.targets)) { + if (target) { + project.targets.set(name, target); + } + } + } + + for (const [name, value] of Object.entries(definition)) { + switch (name) { + case 'name': + case 'root': + case 'sourceRoot': + case 'prefix': + case 'targets': + break; + default: + if (isJsonValue(value)) { + project.extensions[name] = value; + } else { + throw new TypeError(`"${name}" must be a JSON value.`); + } + break; + } + } + + super.set(definition.name, project); + + return project; + } + + override set(name: string, value: ProjectDefinition): this { + this._validateName(name); + + super.set(name, value); + + return this; + } + + private _validateName(name: string): void { + if (typeof name !== 'string' || !/^(?:@\w[\w.-]*\/)?\w[\w.-]*$/.test(name)) { + throw new Error('Project name must be a valid npm package name.'); + } + } +} + +export class TargetDefinitionCollection extends DefinitionCollection { + constructor( + initial?: Record, + listener?: DefinitionCollectionListener, + ) { + super(initial, listener); + } + + add( + definition: { + name: string; + } & TargetDefinition, + ): TargetDefinition { + if (this.has(definition.name)) { + throw new Error('Target name already exists.'); + } + this._validateName(definition.name); + + const target = { + builder: definition.builder, + options: definition.options, + configurations: definition.configurations, + defaultConfiguration: definition.defaultConfiguration, + }; + + super.set(definition.name, target); + + return target; + } + + override set(name: string, value: TargetDefinition): this { + this._validateName(name); + + super.set(name, value); + + return this; + } + + private _validateName(name: string): void { + if (typeof name !== 'string') { + throw new TypeError('Target name must be a string.'); + } + } +} diff --git a/packages/angular_devkit/core/src/workspace/definitions_spec.ts b/packages/angular_devkit/core/src/workspace/definitions_spec.ts new file mode 100644 index 000000000000..ec76e5b5d5a4 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/definitions_spec.ts @@ -0,0 +1,273 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + ProjectDefinition, + ProjectDefinitionCollection, + TargetDefinition, + TargetDefinitionCollection, +} from './definitions'; + +describe('ProjectDefinitionCollection', () => { + it('can be created without initial values or a listener', () => { + const collection = new ProjectDefinitionCollection(); + + expect(collection.size).toBe(0); + }); + + it('can be created with initial values', () => { + const initial = { + 'my-app': { root: 'my-app', extensions: {}, targets: new TargetDefinitionCollection() }, + 'my-lib': { root: 'my-lib', extensions: {}, targets: new TargetDefinitionCollection() }, + }; + + initial['my-app'].targets.add({ + name: 'build', + builder: 'build-builder', + }); + + const collection = new ProjectDefinitionCollection(initial); + + expect(collection.size).toBe(2); + + const app = collection.get('my-app'); + expect(app).not.toBeUndefined(); + if (app) { + expect(app.root).toBe('my-app'); + expect(app.extensions).toEqual({}); + expect(app.targets.size).toBe(1); + expect(app.targets.get('build')).not.toBeUndefined(); + } + const lib = collection.get('my-lib'); + expect(lib).not.toBeUndefined(); + if (lib) { + expect(lib.root).toBe('my-lib'); + expect(lib.extensions).toEqual({}); + expect(lib.targets).toBeTruthy(); + } + }); + + it('can be created with a listener', () => { + const listener = () => { + fail('listener should not execute on initialization'); + }; + + const collection = new ProjectDefinitionCollection(undefined, listener); + + expect(collection.size).toBe(0); + }); + + it('can be created with initial values and a listener', () => { + const initial = { + 'my-app': { root: 'src/my-app', extensions: {}, targets: new TargetDefinitionCollection() }, + 'my-lib': { root: 'src/my-lib', extensions: {}, targets: new TargetDefinitionCollection() }, + }; + + initial['my-app'].targets.add({ + name: 'build', + builder: 'build-builder', + }); + + const listener = () => { + fail('listener should not execute on initialization'); + }; + + const collection = new ProjectDefinitionCollection(initial, listener); + + expect(collection.size).toBe(2); + + const app = collection.get('my-app'); + expect(app).not.toBeUndefined(); + if (app) { + expect(app.root).toBe('src/my-app'); + expect(app.extensions).toEqual({}); + expect(app.targets.size).toBe(1); + expect(app.targets.get('build')).not.toBeUndefined(); + } + const lib = collection.get('my-lib'); + expect(lib).not.toBeUndefined(); + if (lib) { + expect(lib.root).toBe('src/my-lib'); + expect(lib.extensions).toEqual({}); + expect(lib.targets).toBeTruthy(); + } + }); + + it('listens to an addition via set', () => { + const listener = (name: string, value?: ProjectDefinition) => { + expect(name).toBe('my-app'); + expect(value?.root).toBe('src/my-app'); + }; + + const collection = new ProjectDefinitionCollection(undefined, listener); + + collection.set('my-app', { + root: 'src/my-app', + extensions: {}, + targets: new TargetDefinitionCollection(), + }); + }); + + it('listens to an addition via add', () => { + const listener = (name: string, value?: ProjectDefinition) => { + expect(name).toBe('my-app'); + expect(value?.root).toBe('src/my-app'); + }; + + const collection = new ProjectDefinitionCollection(undefined, listener); + + collection.add({ + name: 'my-app', + root: 'src/my-app', + }); + }); + + it('listens to a removal', () => { + const initial = { + 'my-app': { root: 'src/my-app', extensions: {}, targets: new TargetDefinitionCollection() }, + }; + + const listener = (name: string, value?: ProjectDefinition) => { + expect(name).toBe('my-app'); + expect(value).toBeUndefined(); + }; + + const collection = new ProjectDefinitionCollection(initial, listener); + + collection.delete('my-app'); + }); + + it('listens to a replacement', () => { + const initial = { + 'my-app': { root: 'src/my-app', extensions: {}, targets: new TargetDefinitionCollection() }, + }; + + const listener = (name: string, value?: ProjectDefinition) => { + expect(name).toBe('my-app'); + expect(value?.root).toBe('src/my-app2'); + }; + + const collection = new ProjectDefinitionCollection(initial, listener); + + collection.set('my-app', { + root: 'src/my-app2', + extensions: {}, + targets: new TargetDefinitionCollection(), + }); + }); +}); + +describe('TargetDefinitionCollection', () => { + it('can be created without initial values or a listener', () => { + const collection = new TargetDefinitionCollection(); + + expect(collection.size).toBe(0); + }); + + it('can be created with initial values', () => { + const initial = { + 'build': { builder: 'builder:build' }, + 'test': { builder: 'builder:test' }, + }; + + const collection = new TargetDefinitionCollection(initial); + + expect(collection.size).toBe(2); + + const build = collection.get('build'); + expect(build).not.toBeUndefined(); + expect(build?.builder).toBe('builder:build'); + const test = collection.get('test'); + expect(test).not.toBeUndefined(); + expect(test?.builder).toBe('builder:test'); + }); + + it('can be created with a listener', () => { + const listener = () => { + fail('listener should not execute on initialization'); + }; + + const collection = new TargetDefinitionCollection(undefined, listener); + + expect(collection.size).toBe(0); + }); + + it('can be created with initial values and a listener', () => { + const initial = { + 'build': { builder: 'builder:build' }, + 'test': { builder: 'builder:test' }, + }; + + const listener = () => { + fail('listener should not execute on initialization'); + }; + + const collection = new TargetDefinitionCollection(initial, listener); + + expect(collection.size).toBe(2); + + const build = collection.get('build'); + expect(build?.builder).toBe('builder:build'); + const test = collection.get('test'); + expect(test?.builder).toBe('builder:test'); + }); + + it('listens to an addition via set', () => { + const listener = (name: string, value?: TargetDefinition) => { + expect(name).toBe('build'); + expect(value?.builder).toBe('builder:build'); + }; + + const collection = new TargetDefinitionCollection(undefined, listener); + + collection.set('build', { builder: 'builder:build' }); + }); + + it('listens to an addition via add', () => { + const listener = (name: string, value?: TargetDefinition) => { + expect(name).toBe('build'); + expect(value?.builder).toBe('builder:build'); + }; + + const collection = new TargetDefinitionCollection(undefined, listener); + + collection.add({ + name: 'build', + builder: 'builder:build', + }); + }); + + it('listens to a removal', () => { + const initial = { + 'build': { builder: 'builder:build' }, + }; + + const listener = (name: string, value?: TargetDefinition) => { + expect(name).toBe('build'); + }; + + const collection = new TargetDefinitionCollection(initial, listener); + + collection.delete('build'); + }); + + it('listens to a replacement', () => { + const initial = { + 'build': { builder: 'builder:build' }, + }; + + const listener = (name: string, value?: TargetDefinition) => { + expect(name).toBe('build'); + expect(value?.builder).toBe('builder:test'); + }; + + const collection = new TargetDefinitionCollection(initial, listener); + + collection.set('build', { builder: 'builder:test' }); + }); +}); diff --git a/packages/angular_devkit/core/src/workspace/host.ts b/packages/angular_devkit/core/src/workspace/host.ts new file mode 100644 index 000000000000..ab973717ba3d --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/host.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { lastValueFrom } from 'rxjs'; +import { normalize, virtualFs } from '../virtual-fs'; + +export interface WorkspaceHost { + readFile(path: string): Promise; + writeFile(path: string, data: string): Promise; + + isDirectory(path: string): Promise; + isFile(path: string): Promise; + + // Potential future additions + // readDirectory?(path: string): Promise; +} + +export function createWorkspaceHost(host: virtualFs.Host): WorkspaceHost { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + + const workspaceHost: WorkspaceHost = { + async readFile(path: string): Promise { + const data = await lastValueFrom(host.read(normalize(path))); + + return decoder.decode(data); + }, + async writeFile(path: string, data: string): Promise { + return lastValueFrom(host.write(normalize(path), encoder.encode(data).buffer)); + }, + async isDirectory(path: string): Promise { + try { + return await lastValueFrom(host.isDirectory(normalize(path))); + } catch { + // some hosts throw if path does not exist + return false; + } + }, + async isFile(path: string): Promise { + try { + return await lastValueFrom(host.isFile(normalize(path))); + } catch { + // some hosts throw if path does not exist + return false; + } + }, + }; + + return workspaceHost; +} diff --git a/packages/angular_devkit/core/src/workspace/host_spec.ts b/packages/angular_devkit/core/src/workspace/host_spec.ts new file mode 100644 index 000000000000..c3af99a36215 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/host_spec.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { test } from '../virtual-fs/host'; +import { WorkspaceHost, createWorkspaceHost } from './host'; + +describe('createWorkspaceHost', () => { + let testHost: test.TestHost; + let workspaceHost: WorkspaceHost; + + beforeEach(() => { + testHost = new test.TestHost({ + 'abc.txt': 'abcdefg', + 'foo/bar.json': '{}', + }); + workspaceHost = createWorkspaceHost(testHost); + }); + + it('supports isFile', async () => { + expect(await workspaceHost.isFile('abc.txt')).toBeTruthy(); + expect(await workspaceHost.isFile('foo/bar.json')).toBeTruthy(); + expect(await workspaceHost.isFile('foo\\bar.json')).toBeTruthy(); + + expect(await workspaceHost.isFile('foo')).toBeFalsy(); + expect(await workspaceHost.isFile('not.there')).toBeFalsy(); + }); + + it('supports isDirectory', async () => { + expect(await workspaceHost.isDirectory('foo')).toBeTruthy(); + expect(await workspaceHost.isDirectory('foo/')).toBeTruthy(); + expect(await workspaceHost.isDirectory('foo\\')).toBeTruthy(); + + expect(await workspaceHost.isDirectory('abc.txt')).toBeFalsy(); + expect(await workspaceHost.isDirectory('foo/bar.json')).toBeFalsy(); + expect(await workspaceHost.isDirectory('not.there')).toBeFalsy(); + }); + + it('supports readFile', async () => { + expect(await workspaceHost.readFile('abc.txt')).toBe('abcdefg'); + }); + + it('supports writeFile', async () => { + await workspaceHost.writeFile('newfile', 'baz'); + expect(testHost.files.sort() as string[]).toEqual(['/abc.txt', '/foo/bar.json', '/newfile']); + + expect(testHost.$read('newfile')).toBe('baz'); + }); +}); diff --git a/packages/angular_devkit/core/src/workspace/index.ts b/packages/angular_devkit/core/src/workspace/index.ts index 13517e107f8d..cdecbfafcb0d 100644 --- a/packages/angular_devkit/core/src/workspace/index.ts +++ b/packages/angular_devkit/core/src/workspace/index.ts @@ -1,10 +1,11 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -export * from './workspace'; -export * from './workspace-schema'; +export * from './definitions'; +export { type WorkspaceHost, createWorkspaceHost } from './host'; +export { WorkspaceFormat, readWorkspace, writeWorkspace } from './core'; diff --git a/packages/angular_devkit/core/src/workspace/json/metadata.ts b/packages/angular_devkit/core/src/workspace/json/metadata.ts new file mode 100644 index 000000000000..dc2965339c82 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/metadata.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { JSONPath, Node, findNodeAtLocation, getNodeValue } from 'jsonc-parser'; +import { JsonValue } from '../../json'; +import { ProjectDefinition, TargetDefinition, WorkspaceDefinition } from '../definitions'; + +export const JsonWorkspaceSymbol: unique symbol = Symbol.for('@angular/core:workspace-json'); + +export interface JsonWorkspaceDefinition extends WorkspaceDefinition { + [JsonWorkspaceSymbol]: JsonWorkspaceMetadata; +} + +interface ChangeValues { + json: JsonValue; + project: ProjectDefinition; + target: TargetDefinition; + projectcollection: Iterable<[string, ProjectDefinition]>; + targetcollection: Iterable<[string, TargetDefinition]>; +} + +export interface JsonChange { + value?: unknown; + type?: keyof ChangeValues; + jsonPath: string[]; +} + +function escapeKey(key: string): string | number { + return key.replace('~', '~0').replace('/', '~1'); +} + +export class JsonWorkspaceMetadata { + readonly changes: Map = new Map(); + + hasLegacyTargetsName = true; + + constructor( + readonly filePath: string, + private readonly ast: Node, + readonly raw: string, + ) {} + + get hasChanges(): boolean { + return this.changes.size > 0; + } + + get changeCount(): number { + return this.changes.size; + } + + getNodeValueFromAst(path: JSONPath): unknown { + const node = findNodeAtLocation(this.ast, path); + + return node && getNodeValue(node); + } + + findChangesForPath(path: string): JsonChange | undefined { + return this.changes.get(path); + } + + addChange( + jsonPath: string[], + value: ChangeValues[T] | undefined, + type?: T, + ): void { + let currentPath = ''; + for (let index = 0; index < jsonPath.length - 1; index++) { + currentPath = currentPath + '/' + escapeKey(jsonPath[index]); + if (this.changes.has(currentPath)) { + // Ignore changes on children as parent is updated. + return; + } + } + + const pathKey = '/' + jsonPath.map((k) => escapeKey(k)).join('/'); + for (const key of this.changes.keys()) { + if (key.startsWith(pathKey + '/')) { + // changes on the same or child paths are redundant. + this.changes.delete(key); + } + } + + this.changes.set(pathKey, { jsonPath, type, value }); + } +} diff --git a/packages/angular_devkit/core/src/workspace/json/reader.ts b/packages/angular_devkit/core/src/workspace/json/reader.ts new file mode 100644 index 000000000000..cc258bf00e08 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/reader.ts @@ -0,0 +1,300 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Node, findNodeAtLocation, getNodeValue, parseTree } from 'jsonc-parser'; +import { JsonValue, isJsonObject } from '../../json/utils'; +import { + DefinitionCollectionListener, + ProjectDefinition, + ProjectDefinitionCollection, + TargetDefinition, + TargetDefinitionCollection, + WorkspaceDefinition, +} from '../definitions'; +import { WorkspaceHost } from '../host'; +import { JsonWorkspaceMetadata, JsonWorkspaceSymbol } from './metadata'; +import { createVirtualAstObject } from './utilities'; + +const ANGULAR_WORKSPACE_EXTENSIONS = Object.freeze(['cli', 'newProjectRoot', 'schematics']); +const ANGULAR_PROJECT_EXTENSIONS = Object.freeze(['cli', 'schematics', 'projectType', 'i18n']); + +interface ParserContext { + readonly host: WorkspaceHost; + readonly metadata: JsonWorkspaceMetadata; + readonly trackChanges: boolean; + readonly unprefixedWorkspaceExtensions: ReadonlySet; + readonly unprefixedProjectExtensions: ReadonlySet; + error(message: string, node: JsonValue): void; + warn(message: string, node: JsonValue): void; +} + +export interface JsonWorkspaceOptions { + allowedProjectExtensions?: string[]; + allowedWorkspaceExtensions?: string[]; +} + +export async function readJsonWorkspace( + path: string, + host: WorkspaceHost, + options: JsonWorkspaceOptions = {}, +): Promise { + const raw = await host.readFile(path); + if (raw === undefined) { + throw new Error('Unable to read workspace file.'); + } + + const ast = parseTree(raw, undefined, { allowTrailingComma: true, disallowComments: false }); + if (ast?.type !== 'object' || !ast.children) { + throw new Error('Invalid workspace file - expected JSON object.'); + } + + // Version check + const versionNode = findNodeAtLocation(ast, ['version']); + if (!versionNode) { + throw new Error('Unknown format - version specifier not found.'); + } + const version = versionNode.value; + if (version !== 1) { + throw new Error(`Invalid format version detected - Expected:[ 1 ] Found: [ ${version} ]`); + } + + const context: ParserContext = { + host, + metadata: new JsonWorkspaceMetadata(path, ast, raw), + trackChanges: true, + unprefixedWorkspaceExtensions: new Set([ + ...ANGULAR_WORKSPACE_EXTENSIONS, + ...(options.allowedWorkspaceExtensions ?? []), + ]), + unprefixedProjectExtensions: new Set([ + ...ANGULAR_PROJECT_EXTENSIONS, + ...(options.allowedProjectExtensions ?? []), + ]), + error(message, _node) { + // TODO: Diagnostic reporting support + throw new Error(message); + }, + warn(message, _node) { + // TODO: Diagnostic reporting support + // eslint-disable-next-line no-console + console.warn(message); + }, + }; + + const workspace = parseWorkspace(ast, context); + + return workspace; +} + +function parseWorkspace(workspaceNode: Node, context: ParserContext): WorkspaceDefinition { + const jsonMetadata = context.metadata; + let projects; + let extensions: Record | undefined; + if (!context.trackChanges) { + extensions = Object.create(null); + } + + // TODO: `getNodeValue` - looks potentially expensive since it walks the whole tree and instantiates the full object structure each time. + // Might be something to look at moving forward to optimize. + const workspaceNodeValue = getNodeValue(workspaceNode); + for (const [name, value] of Object.entries(workspaceNodeValue)) { + if (name === '$schema' || name === 'version') { + // skip + } else if (name === 'projects') { + const nodes = findNodeAtLocation(workspaceNode, ['projects']); + if (!isJsonObject(value) || !nodes) { + context.error('Invalid "projects" field found; expected an object.', value); + continue; + } + + projects = parseProjectsObject(nodes, context); + } else { + if (!context.unprefixedWorkspaceExtensions.has(name) && !/^[a-z]{1,3}-.*/.test(name)) { + context.warn(`Workspace extension with invalid name (${name}) found.`, name); + } + if (extensions) { + extensions[name] = value; + } + } + } + + let collectionListener: DefinitionCollectionListener | undefined; + if (context.trackChanges) { + collectionListener = (name, newValue) => { + jsonMetadata.addChange(['projects', name], newValue, 'project'); + }; + } + + const projectCollection = new ProjectDefinitionCollection(projects, collectionListener); + + return { + [JsonWorkspaceSymbol]: jsonMetadata, + projects: projectCollection, + // If not tracking changes the `extensions` variable will contain the parsed + // values. Otherwise the extensions are tracked via a virtual AST object. + extensions: + extensions ?? + createVirtualAstObject(workspaceNodeValue, { + exclude: ['$schema', 'version', 'projects'], + listener(path, value) { + jsonMetadata.addChange(path, value); + }, + }), + } as WorkspaceDefinition; +} + +function parseProjectsObject( + projectsNode: Node, + context: ParserContext, +): Record { + const projects: Record = Object.create(null); + + for (const [name, value] of Object.entries(getNodeValue(projectsNode))) { + const nodes = findNodeAtLocation(projectsNode, [name]); + if (!isJsonObject(value) || !nodes) { + context.warn('Skipping invalid project value; expected an object.', value); + continue; + } + + projects[name] = parseProject(name, nodes, context); + } + + return projects; +} + +function parseProject( + projectName: string, + projectNode: Node, + context: ParserContext, +): ProjectDefinition { + const jsonMetadata = context.metadata; + let targets; + let hasTargets = false; + let extensions: Record | undefined; + let properties: Record<'root' | 'sourceRoot' | 'prefix', string> | undefined; + if (!context.trackChanges) { + // If not tracking changes, the parser will store the values directly in standard objects + extensions = Object.create(null); + properties = Object.create(null); + } + + const projectNodeValue = getNodeValue(projectNode); + if (!('root' in projectNodeValue)) { + throw new Error(`Project "${projectName}" is missing a required property "root".`); + } + + for (const [name, value] of Object.entries(projectNodeValue)) { + switch (name) { + case 'targets': + case 'architect': { + const nodes = findNodeAtLocation(projectNode, [name]); + if (!isJsonObject(value) || !nodes) { + context.error(`Invalid "${name}" field found; expected an object.`, value); + break; + } + hasTargets = true; + targets = parseTargetsObject(projectName, nodes, context); + jsonMetadata.hasLegacyTargetsName = name === 'architect'; + break; + } + case 'prefix': + case 'root': + case 'sourceRoot': + if (typeof value !== 'string') { + context.warn(`Project property "${name}" should be a string.`, value); + } + if (properties) { + properties[name] = value as string; + } + break; + default: + if (!context.unprefixedProjectExtensions.has(name) && !/^[a-z]{1,3}-.*/.test(name)) { + context.warn( + `Project '${projectName}' contains extension with invalid name (${name}).`, + name, + ); + } + if (extensions) { + extensions[name] = value; + } + break; + } + } + + let collectionListener: DefinitionCollectionListener | undefined; + if (context.trackChanges) { + collectionListener = (name, newValue, collection) => { + if (hasTargets) { + jsonMetadata.addChange(['projects', projectName, 'targets', name], newValue, 'target'); + } else { + jsonMetadata.addChange( + ['projects', projectName, 'targets'], + collection, + 'targetcollection', + ); + } + }; + } + + const base = { + targets: new TargetDefinitionCollection(targets, collectionListener), + // If not tracking changes the `extensions` variable will contain the parsed + // values. Otherwise the extensions are tracked via a virtual AST object. + extensions: + extensions ?? + createVirtualAstObject(projectNodeValue, { + exclude: ['architect', 'prefix', 'root', 'sourceRoot', 'targets'], + listener(path, value) { + jsonMetadata.addChange(['projects', projectName, ...path], value); + }, + }), + }; + + const baseKeys = new Set(Object.keys(base)); + const project = + properties ?? + createVirtualAstObject(projectNodeValue, { + include: ['prefix', 'root', 'sourceRoot', ...baseKeys], + listener(path, value) { + if (!baseKeys.has(path[0])) { + jsonMetadata.addChange(['projects', projectName, ...path], value); + } + }, + }); + + return Object.assign(project, base) as ProjectDefinition; +} + +function parseTargetsObject( + projectName: string, + targetsNode: Node, + context: ParserContext, +): Record { + const jsonMetadata = context.metadata; + const targets: Record = Object.create(null); + + for (const [name, value] of Object.entries(getNodeValue(targetsNode))) { + if (!isJsonObject(value)) { + context.warn('Skipping invalid target value; expected an object.', value); + continue; + } + + if (context.trackChanges) { + targets[name] = createVirtualAstObject(value, { + include: ['builder', 'options', 'configurations', 'defaultConfiguration'], + listener(path, value) { + jsonMetadata.addChange(['projects', projectName, 'targets', name, ...path], value); + }, + }); + } else { + targets[name] = value as unknown as TargetDefinition; + } + } + + return targets; +} diff --git a/packages/angular_devkit/core/src/workspace/json/reader_spec.ts b/packages/angular_devkit/core/src/workspace/json/reader_spec.ts new file mode 100644 index 000000000000..041cf580ce0b --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/reader_spec.ts @@ -0,0 +1,767 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use 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 @typescript-eslint/no-non-null-assertion */ +import { readFileSync } from 'node:fs'; +import { JsonObject } from '../../json'; +import { stripIndent } from '../../utils/literals'; +import { TargetDefinitionCollection, WorkspaceDefinition } from '../definitions'; +import { JsonWorkspaceDefinition, JsonWorkspaceMetadata, JsonWorkspaceSymbol } from './metadata'; +import { readJsonWorkspace } from './reader'; + +const basicFile = stripIndent` +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, +}`; + +const representativeFile = readFileSync(require.resolve(__dirname + '/test/angular.json'), 'utf8'); + +function createTestHost(content: string, onWrite?: (path: string, data: string) => void) { + return { + async readFile() { + return content; + }, + async writeFile(path: string, data: string) { + if (onWrite) { + onWrite(path, data); + } + }, + async isFile() { + return true; + }, + async isDirectory() { + return true; + }, + }; +} + +function getMetadata(workspace: WorkspaceDefinition): JsonWorkspaceMetadata { + const metadata = (workspace as JsonWorkspaceDefinition)[JsonWorkspaceSymbol]; + expect(metadata).toBeDefined(); + + return metadata; +} + +describe('readJsonWorkpace Parsing', () => { + it('parses a basic file', async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace('basic', host); + + expect(workspace.projects.size).toBe(0); + expect(workspace.extensions['x-bar']).toBe(5); + expect(workspace.extensions['schematics']).toEqual({ + '@angular/schematics:component': { prefix: 'abc' }, + }); + }); + + it('parses a representative file', async () => { + const host = createTestHost(representativeFile); + + const workspace = await readJsonWorkspace('', host); + + expect(Array.from(workspace.projects.keys())).toEqual(['my-app']); + expect(workspace.extensions['newProjectRoot']).toBe('projects'); + expect(workspace.projects.get('my-app')!.extensions['schematics']).toEqual({ + '@schematics/angular:component': { styleext: 'scss' }, + }); + expect(workspace.projects.get('my-app')!.root).toBe(''); + expect(workspace.projects.get('my-app')!.targets.get('build')!.builder).toBe( + '@angular-devkit/build-angular:browser', + ); + }); + + it(`doesn't remove falsy values when using the spread operator`, async () => { + const host = createTestHost(representativeFile); + const workspace = await readJsonWorkspace('', host); + const prodConfig = workspace.projects.get('my-app')!.targets.get('build')!.configurations! + .production!; + expect({ ...prodConfig }).toEqual(prodConfig); + }); + + it('parses extensions only into extensions object', async () => { + const host = createTestHost(representativeFile); + + const workspace = await readJsonWorkspace('', host); + + expect(workspace.extensions['newProjectRoot']).toBe('projects'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((workspace as any)['newProjectRoot']).toBeUndefined(); + + expect(workspace.projects.get('my-app')!.extensions['schematics']).toEqual({ + '@schematics/angular:component': { styleext: 'scss' }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((workspace.projects.get('my-app') as any)['schematics']).toBeUndefined(); + }); + + it('errors on invalid version', async () => { + const host = createTestHost(stripIndent` + { + "version": 99, + // Comment + "x-bar": 5, + } + `); + + await expectAsync(readJsonWorkspace('', host)).toBeRejectedWithError( + /Invalid format version detected/, + ); + }); + + it('errors on missing version', async () => { + const host = createTestHost(stripIndent` + { + // Comment + "x-bar": 5, + } + `); + + await expectAsync(readJsonWorkspace('', host)).toBeRejectedWithError( + /version specifier not found/, + ); + }); + + it('warns on missing root property in a project', async () => { + const host = createTestHost(stripIndent` + { + "version": 1, + "projects": { + "foo": {} + } + } + `); + + await expectAsync(readJsonWorkspace('', host)).toBeRejectedWithError( + /Project "foo" is missing a required property "root"/, + ); + }); +}); + +describe('JSON WorkspaceDefinition Tracks Workspace Changes', () => { + it('tracks basic extension additions', async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace('', host); + + workspace.extensions['x-baz'] = 101; + expect(workspace.extensions['x-baz']).toBe(101); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath('/x-baz'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toBe(101); + } + }); + + it('tracks complex extension additions', async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace('', host); + + const value = { a: 1, b: 2, c: { d: 'abc' } }; + workspace.extensions['x-baz'] = value; + expect(workspace.extensions['x-baz']).toEqual({ a: 1, b: 2, c: { d: 'abc' } }); + + value.b = 3; + expect(workspace.extensions['x-baz']).toEqual({ a: 1, b: 3, c: { d: 'abc' } }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath('/x-baz'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual({ a: 1, b: 3, c: { d: 'abc' } }); + } + }); + + it('tracks complex extension additions with Object.assign target', async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace('', host); + + const value = { a: 1, b: 2, c: { d: 'abc' } }; + workspace.extensions['x-baz'] = value; + expect(workspace.extensions['x-baz']).toEqual({ a: 1, b: 2, c: { d: 'abc' } }); + + Object.assign(value, { x: 9, y: 8, z: 7 }); + expect(workspace.extensions['x-baz']).toEqual({ + a: 1, + b: 2, + c: { d: 'abc' }, + x: 9, + y: 8, + z: 7, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath('/x-baz'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual({ a: 1, b: 2, c: { d: 'abc' }, x: 9, y: 8, z: 7 }); + } + }); + + it('tracks complex extension additions with Object.assign return', async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace('', host); + + const value = { a: 1, b: 2, c: { d: 'abc' } }; + workspace.extensions['x-baz'] = value; + expect(workspace.extensions['x-baz']).toEqual({ a: 1, b: 2, c: { d: 'abc' } }); + + workspace.extensions['x-baz'] = Object.assign(value, { x: 9, y: 8, z: 7 }); + expect(workspace.extensions['x-baz']).toEqual({ + a: 1, + b: 2, + c: { d: 'abc' }, + x: 9, + y: 8, + z: 7, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath('/x-baz'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual({ a: 1, b: 2, c: { d: 'abc' }, x: 9, y: 8, z: 7 }); + } + }); + + it('tracks complex extension additions with spread operator', async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace('', host); + + const value = { a: 1, b: 2, c: { d: 'abc' } }; + workspace.extensions['x-baz'] = value; + expect(workspace.extensions['x-baz']).toEqual({ a: 1, b: 2, c: { d: 'abc' } }); + + workspace.extensions['x-baz'] = { ...value, ...{ x: 9, y: 8 }, z: 7 }; + expect(workspace.extensions['x-baz']).toEqual({ + a: 1, + b: 2, + c: { d: 'abc' }, + x: 9, + y: 8, + z: 7, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath('/x-baz'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual({ a: 1, b: 2, c: { d: 'abc' }, x: 9, y: 8, z: 7 }); + } + }); + + it('tracks modifying an existing extension object with spread operator', async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace('', host); + + workspace.extensions['x-foo'] = { + ...(workspace.extensions['x-foo'] as JsonObject), + ...{ x: 9, y: 8 }, + z: 7, + }; + expect(workspace.extensions['x-foo']).toEqual({ + is: ['good', 'great', 'awesome'], + x: 9, + y: 8, + z: 7, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath('/x-foo'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual({ is: ['good', 'great', 'awesome'], x: 9, y: 8, z: 7 }); + } + }); + + it('tracks modifying an existing extension object with Object.assign target', async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace('', host); + + Object.assign(workspace.extensions['x-foo']!, { x: 9, y: 8 }, { z: 7 }); + expect(workspace.extensions['x-foo']).toEqual({ + is: ['good', 'great', 'awesome'], + x: 9, + y: 8, + z: 7, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(3); + + let change = metadata.findChangesForPath('/x-foo/x'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual(9); + } + + change = metadata.findChangesForPath('/x-foo/y'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual(8); + } + + change = metadata.findChangesForPath('/x-foo/z'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual(7); + } + }); + + it('tracks modifying an existing extension object with Object.assign return', async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace('', host); + + workspace.extensions['x-foo'] = Object.assign( + workspace.extensions['x-foo']!, + { x: 9, y: 8 }, + { z: 7 }, + ); + expect(workspace.extensions['x-foo']).toEqual({ + is: ['good', 'great', 'awesome'], + x: 9, + y: 8, + z: 7, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(3); + + let change = metadata.findChangesForPath('/x-foo/x'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual(9); + } + + change = metadata.findChangesForPath('/x-foo/y'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual(8); + } + + change = metadata.findChangesForPath('/x-foo/z'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual(7); + } + }); + + it('tracks add and remove of an existing extension object', async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace('basic', host); + + workspace.extensions['schematics2'] = workspace.extensions['schematics']; + + expect(workspace.extensions['schematics']).toEqual({ + '@angular/schematics:component': { prefix: 'abc' }, + }); + expect(workspace.extensions['schematics2']).toEqual({ + '@angular/schematics:component': { prefix: 'abc' }, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + let change = metadata.findChangesForPath('/schematics2'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual({ '@angular/schematics:component': { prefix: 'abc' } }); + } + + workspace.extensions['schematics'] = undefined; + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(2); + + change = metadata.findChangesForPath('/schematics'); + expect(change).not.toBeUndefined(); + }); + + it('tracks moving and modifying an existing extension object', async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace('basic', host); + + workspace.extensions['schematics2'] = workspace.extensions['schematics']; + + expect(workspace.extensions['schematics']).toEqual({ + '@angular/schematics:component': { prefix: 'abc' }, + }); + expect(workspace.extensions['schematics2']).toEqual({ + '@angular/schematics:component': { prefix: 'abc' }, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + let change = metadata.findChangesForPath('/schematics2'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual({ '@angular/schematics:component': { prefix: 'abc' } }); + } + + workspace.extensions['schematics'] = undefined; + (workspace.extensions['schematics2'] as JsonObject)['@angular/schematics:component'] = { + prefix: 'xyz', + }; + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(2); + + change = metadata.findChangesForPath('/schematics'); + expect(change).not.toBeUndefined(); + + change = metadata.findChangesForPath('/schematics2'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual({ '@angular/schematics:component': { prefix: 'xyz' } }); + } + }); + + it('tracks copying and modifying an existing extension object', async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace('basic', host); + + workspace.extensions['schematics2'] = workspace.extensions['schematics']; + + expect(workspace.extensions['schematics']).toEqual({ + '@angular/schematics:component': { prefix: 'abc' }, + }); + expect(workspace.extensions['schematics2']).toEqual({ + '@angular/schematics:component': { prefix: 'abc' }, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + let change = metadata.findChangesForPath('/schematics2'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual({ '@angular/schematics:component': { prefix: 'abc' } }); + } + + (workspace.extensions['schematics2'] as JsonObject)['@angular/schematics:component'] = { + prefix: 'xyz', + }; + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(2); + + change = metadata.findChangesForPath('/schematics/@angular~1schematics:component'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual({ prefix: 'xyz' }); + } + + change = metadata.findChangesForPath('/schematics2'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual({ '@angular/schematics:component': { prefix: 'xyz' } }); + } + }); + + it('tracks copying, modifying, and removing an existing extension object', async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace('basic', host); + + workspace.extensions['schematics2'] = workspace.extensions['schematics']; + + expect(workspace.extensions['schematics']).toEqual({ + '@angular/schematics:component': { prefix: 'abc' }, + }); + expect(workspace.extensions['schematics2']).toEqual({ + '@angular/schematics:component': { prefix: 'abc' }, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + let change = metadata.findChangesForPath('/schematics2'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual({ '@angular/schematics:component': { prefix: 'abc' } }); + } + + (workspace.extensions['schematics2'] as JsonObject)['@angular/schematics:component'] = { + prefix: 'xyz', + }; + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(2); + + change = metadata.findChangesForPath('/schematics/@angular~1schematics:component'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual({ prefix: 'xyz' }); + } + + change = metadata.findChangesForPath('/schematics2'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual({ '@angular/schematics:component': { prefix: 'xyz' } }); + } + + workspace.extensions['schematics'] = undefined; + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(2); + + change = metadata.findChangesForPath('/schematics'); + expect(change).not.toBeUndefined(); + + const orderedChanges = Array.from(metadata.changes.values()); + change = orderedChanges[1]; + expect(change).not.toBeUndefined(); + }); + + it('tracks project additions with set', async () => { + const host = createTestHost(representativeFile); + + const workspace = await readJsonWorkspace('', host); + + workspace.projects.set('new-app', { + root: 'src', + extensions: {}, + targets: new TargetDefinitionCollection(), + }); + + const app = workspace.projects.get('new-app'); + expect(app).not.toBeUndefined(); + if (app) { + expect(app.extensions).toEqual({}); + expect(app.root).toBe('src'); + } + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath('/projects/new-app'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual(jasmine.objectContaining({ root: 'src' })); + } + }); + + it('tracks project additions with add', async () => { + const host = createTestHost(representativeFile); + + const workspace = await readJsonWorkspace('', host); + + workspace.projects.add({ + name: 'new-app', + root: 'src', + }); + + const app = workspace.projects.get('new-app'); + expect(app).not.toBeUndefined(); + if (app) { + expect(app.extensions).toEqual({}); + expect(app.root).toBe('src'); + } + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath('/projects/new-app'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toEqual(jasmine.objectContaining({ root: 'src' })); + } + }); +}); + +describe('JSON ProjectDefinition Tracks Project Changes', () => { + it('tracks property changes', async () => { + const host = createTestHost(representativeFile); + + const workspace = await readJsonWorkspace('', host); + + const project = workspace.projects.get('my-app'); + expect(project).not.toBeUndefined(); + if (!project) { + return; + } + + project.prefix = 'bar'; + expect(project.prefix).toBe('bar'); + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath('/projects/my-app/prefix'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toBe('bar'); + } + }); + + it('tracks property additions', async () => { + const host = createTestHost(representativeFile); + + const workspace = await readJsonWorkspace('', host); + + const project = workspace.projects.get('my-app'); + expect(project).not.toBeUndefined(); + if (!project) { + return; + } + + expect(project.sourceRoot).toBeUndefined(); + project.sourceRoot = 'xyz'; + expect(project.sourceRoot).toBe('xyz'); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath('/projects/my-app/sourceRoot'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toBe('xyz'); + } + }); + + it('tracks extension additions', async () => { + const host = createTestHost(representativeFile); + + const workspace = await readJsonWorkspace('', host); + + const project = workspace.projects.get('my-app'); + expect(project).not.toBeUndefined(); + if (!project) { + return; + } + + expect(project.extensions['abc-option']).toBeUndefined(); + project.extensions['abc-option'] = 'valueA'; + expect(project.extensions['abc-option']).toBe('valueA'); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath('/projects/my-app/abc-option'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.value).toBe('valueA'); + } + }); + + it('tracks target additions with no original target collection', async () => { + const original = stripIndent` + { + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "projects": { + "p1": { + "root": "p1-root" + } + }, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, + } + `; + const host = createTestHost(original); + + const workspace = await readJsonWorkspace('', host); + + const project = workspace.projects.get('p1'); + expect(project).not.toBeUndefined(); + if (!project) { + return; + } + + project.targets.add({ + name: 't1', + builder: 't1-builder', + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath('/projects/p1/targets'); + expect(change).not.toBeUndefined(); + if (change) { + expect(change.type).toBe('targetcollection'); + } + }); +}); diff --git a/packages/angular_devkit/core/src/workspace/json/test/angular.json b/packages/angular_devkit/core/src/workspace/json/test/angular.json new file mode 100644 index 000000000000..285676999acb --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/angular.json @@ -0,0 +1,109 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "my-app": { + "root": "", + "projectType": "application", + "prefix": "app", + "schematics": { + "@schematics/angular:component": { + "styleext": "scss" + } + }, + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/my-app", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + } + ] + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "my-app:build" + }, + "configurations": { + "production": { + "browserTarget": "my-app:build:production" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "my-app:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/test.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "src/karma.conf.js", + "styles": [ + "src/styles.scss" + ], + "scripts": [], + "assets": [ + "src/favicon.ico", + "src/assets" + ] + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": [ + "src/tsconfig.app.json", + "src/tsconfig.spec.json" + ], + "exclude": [ + "**/node_modules/**" + ] + } + } + } + } + } +} diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/AddArrayEmpty.json b/packages/angular_devkit/core/src/workspace/json/test/cases/AddArrayEmpty.json new file mode 100644 index 000000000000..986639153289 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/AddArrayEmpty.json @@ -0,0 +1,15 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, + "x-array": [], +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/AddArrayPush.json b/packages/angular_devkit/core/src/workspace/json/test/cases/AddArrayPush.json new file mode 100644 index 000000000000..12a192f8a858 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/AddArrayPush.json @@ -0,0 +1,17 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, + "x-array": [ + "value" + ], +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/AddProject1.json b/packages/angular_devkit/core/src/workspace/json/test/cases/AddProject1.json new file mode 100644 index 000000000000..7acd961c8c50 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/AddProject1.json @@ -0,0 +1,19 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, + "projects": { + "new": { + "root": "src7" + } + }, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/AddProject2.json b/packages/angular_devkit/core/src/workspace/json/test/cases/AddProject2.json new file mode 100644 index 000000000000..55ec083ffa9f --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/AddProject2.json @@ -0,0 +1,112 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "my-app": { + "root": "", + "projectType": "application", + "prefix": "app", + "schematics": { + "@schematics/angular:component": { + "styleext": "scss" + } + }, + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/my-app", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + } + ] + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "my-app:build" + }, + "configurations": { + "production": { + "browserTarget": "my-app:build:production" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "my-app:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/test.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "src/karma.conf.js", + "styles": [ + "src/styles.scss" + ], + "scripts": [], + "assets": [ + "src/favicon.ico", + "src/assets" + ] + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": [ + "src/tsconfig.app.json", + "src/tsconfig.spec.json" + ], + "exclude": [ + "**/node_modules/**" + ] + } + } + } + }, + "new": { + "root": "src" + } + } +} diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/AddProject3.json b/packages/angular_devkit/core/src/workspace/json/test/cases/AddProject3.json new file mode 100644 index 000000000000..cbd98e4c89ef --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/AddProject3.json @@ -0,0 +1,112 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "my-app": { + "root": "", + "projectType": "application", + "prefix": "app", + "schematics": { + "@schematics/angular:component": { + "styleext": "scss" + } + }, + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/my-app", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + } + ] + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "my-app:build" + }, + "configurations": { + "production": { + "browserTarget": "my-app:build:production" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "my-app:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/test.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "src/karma.conf.js", + "styles": [ + "src/styles.scss" + ], + "scripts": [], + "assets": [ + "src/favicon.ico", + "src/assets" + ] + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": [ + "src/tsconfig.app.json", + "src/tsconfig.spec.json" + ], + "exclude": [ + "**/node_modules/**" + ] + } + } + } + }, + "1": { + "root": "src" + } + } +} diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/AddProjectWithTargets.json b/packages/angular_devkit/core/src/workspace/json/test/cases/AddProjectWithTargets.json new file mode 100644 index 000000000000..d001bfb5d6c8 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/AddProjectWithTargets.json @@ -0,0 +1,33 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, + "projects": { + "new": { + "root": "src", + "architect": { + "build": { + "builder": "build-builder", + "options": { + "one": 1, + "two": false + }, + "configurations": { + "staging": { + "two": true + } + } + } + } + } + }, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayDeleteInner.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayDeleteInner.json new file mode 100644 index 000000000000..96d7743dde4a --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayDeleteInner.json @@ -0,0 +1,17 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": [ + "good", + "awesome" + ] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayDeleteInnerAdd.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayDeleteInnerAdd.json new file mode 100644 index 000000000000..41f9979607d0 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayDeleteInnerAdd.json @@ -0,0 +1,18 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": [ + "good", + "new", + "awesome" + ] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayDeleteLast.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayDeleteLast.json new file mode 100644 index 000000000000..521fa740d0bd --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayDeleteLast.json @@ -0,0 +1,17 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": [ + "good", + "great" + ] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayDeleteLastAdd.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayDeleteLastAdd.json new file mode 100644 index 000000000000..9c27359090c8 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayDeleteLastAdd.json @@ -0,0 +1,18 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": [ + "good", + "great", + "new" + ] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayDeleteZero.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayDeleteZero.json new file mode 100644 index 000000000000..fc0fd058d98f --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayDeleteZero.json @@ -0,0 +1,17 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": [ + "great", + "awesome" + ] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayIndexInner.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayIndexInner.json new file mode 100644 index 000000000000..ac769c3f9444 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayIndexInner.json @@ -0,0 +1,18 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": [ + "good", + "value", + "awesome" + ] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayIndexLast.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayIndexLast.json new file mode 100644 index 000000000000..083e92bd9853 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayIndexLast.json @@ -0,0 +1,18 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": [ + "good", + "great", + "value" + ] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayIndexZero.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayIndexZero.json new file mode 100644 index 000000000000..e384556d2fac --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayIndexZero.json @@ -0,0 +1,18 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": [ + "value", + "great", + "awesome" + ] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayPop.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayPop.json new file mode 100644 index 000000000000..521fa740d0bd --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayPop.json @@ -0,0 +1,17 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": [ + "good", + "great" + ] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayPush.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayPush.json new file mode 100644 index 000000000000..8d9105f5c1d2 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayPush.json @@ -0,0 +1,19 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": [ + "good", + "great", + "awesome", + "value" + ] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayReplace1.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayReplace1.json new file mode 100644 index 000000000000..81a1eb8d0a6c --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayReplace1.json @@ -0,0 +1,16 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": [ + "value" + ] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayReplace2.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayReplace2.json new file mode 100644 index 000000000000..84def4ee3fbb --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayReplace2.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": [] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayShift.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayShift.json new file mode 100644 index 000000000000..fc0fd058d98f --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayShift.json @@ -0,0 +1,17 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": [ + "great", + "awesome" + ] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ArraySort.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ArraySort.json new file mode 100644 index 000000000000..3a98d0c49080 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ArraySort.json @@ -0,0 +1,18 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": [ + "awesome", + "good", + "great" + ] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ArraySplice1.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ArraySplice1.json new file mode 100644 index 000000000000..521fa740d0bd --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ArraySplice1.json @@ -0,0 +1,17 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": [ + "good", + "great" + ] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ArraySplice2.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ArraySplice2.json new file mode 100644 index 000000000000..9f8f9a38f2e0 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ArraySplice2.json @@ -0,0 +1,20 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": [ + "good", + "great", + "value1", + "value2", + "awesome" + ] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayUnshift.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayUnshift.json new file mode 100644 index 000000000000..cfffb7e5355d --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayUnshift.json @@ -0,0 +1,19 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": [ + "value", + "good", + "great", + "awesome" + ] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayValues.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayValues.json new file mode 100644 index 000000000000..97b281a873d4 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ArrayValues.json @@ -0,0 +1,22 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, + "x-array": [ + 5, + "a", + false, + null, + true, + 9.9 + ], +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/Empty.json b/packages/angular_devkit/core/src/workspace/json/test/cases/Empty.json new file mode 100644 index 000000000000..a1fd7e64df9a --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/Empty.json @@ -0,0 +1,5 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "projects": {} +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/Extensions1.json b/packages/angular_devkit/core/src/workspace/json/test/cases/Extensions1.json new file mode 100644 index 000000000000..731bffd356cb --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/Extensions1.json @@ -0,0 +1,6 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": {} +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/Extensions2.json b/packages/angular_devkit/core/src/workspace/json/test/cases/Extensions2.json new file mode 100644 index 000000000000..1d2157b18252 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/Extensions2.json @@ -0,0 +1,11 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "schematics": { + "@schematics/angular:component": { + "prefix": "app" + } + }, + "projects": {} +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ObjectRemove.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ObjectRemove.json new file mode 100644 index 000000000000..3b154abf8bc9 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ObjectRemove.json @@ -0,0 +1,13 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ObjectRemoveMultiple.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ObjectRemoveMultiple.json new file mode 100644 index 000000000000..cf3ae23dcf98 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ObjectRemoveMultiple.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ObjectReplace1.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ObjectReplace1.json new file mode 100644 index 000000000000..634d80e44a75 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ObjectReplace1.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "replacement": true + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ObjectReplace2.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ObjectReplace2.json new file mode 100644 index 000000000000..a5bfe2ec90b0 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ObjectReplace2.json @@ -0,0 +1,12 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": {}, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ObjectReplace3.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ObjectReplace3.json new file mode 100644 index 000000000000..aa3b0128faad --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ObjectReplace3.json @@ -0,0 +1,12 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": null, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ProjectAddTarget.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ProjectAddTarget.json new file mode 100644 index 000000000000..e42eb5a70c6f --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ProjectAddTarget.json @@ -0,0 +1,112 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "my-app": { + "root": "", + "projectType": "application", + "prefix": "app", + "schematics": { + "@schematics/angular:component": { + "styleext": "scss" + } + }, + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/my-app", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + } + ] + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "my-app:build" + }, + "configurations": { + "production": { + "browserTarget": "my-app:build:production" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "my-app:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/test.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "src/karma.conf.js", + "styles": [ + "src/styles.scss" + ], + "scripts": [], + "assets": [ + "src/favicon.ico", + "src/assets" + ] + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": [ + "src/tsconfig.app.json", + "src/tsconfig.spec.json" + ], + "exclude": [ + "**/node_modules/**" + ] + } + }, + "new": { + "builder": "new-builder" + } + } + } + } +} diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ProjectDeleteTarget.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ProjectDeleteTarget.json new file mode 100644 index 000000000000..573a5835564b --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ProjectDeleteTarget.json @@ -0,0 +1,103 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "my-app": { + "root": "", + "projectType": "application", + "prefix": "app", + "schematics": { + "@schematics/angular:component": { + "styleext": "scss" + } + }, + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/my-app", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + } + ] + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "my-app:build" + }, + "configurations": { + "production": { + "browserTarget": "my-app:build:production" + } + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/test.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "src/karma.conf.js", + "styles": [ + "src/styles.scss" + ], + "scripts": [], + "assets": [ + "src/favicon.ico", + "src/assets" + ] + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": [ + "src/tsconfig.app.json", + "src/tsconfig.spec.json" + ], + "exclude": [ + "**/node_modules/**" + ] + } + } + } + } + } +} diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ProjectEmpty.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ProjectEmpty.json new file mode 100644 index 000000000000..fe605d8dc60c --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ProjectEmpty.json @@ -0,0 +1,9 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "projects": { + "my-app": { + "root": "projects/my-app" + } + } +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ProjectFull.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ProjectFull.json new file mode 100644 index 000000000000..a5a2a1afc606 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ProjectFull.json @@ -0,0 +1,67 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "projects": { + "my-app": { + "root": "projects/my-app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/my-app", + "index": "projects/my-app/src/index.html", + "main": "projects/my-app/src/main.ts", + "polyfills": "projects/my-app/src/polyfills.ts", + "tsConfig": "projects/my-app/tsconfig.app.json", + "assets": [ + "projects/my-app/src/favicon.ico", + "projects/my-app/src/assets" + ], + "styles": [ + "projects/my-app/src/styles.scss" + ], + "scripts": [], + "es5BrowserSupport": true + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "projects/my-app/src/environments/environment.ts", + "with": "projects/my-app/src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + } + ] + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "my-app:build" + }, + "configurations": { + "production": { + "browserTarget": "my-app:build:production" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ProjectModifyProperties.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ProjectModifyProperties.json new file mode 100644 index 000000000000..9f6380eb89c0 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ProjectModifyProperties.json @@ -0,0 +1,109 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "my-app": { + "root": "src", + "projectType": "application", + "prefix": "app", + "schematics": { + "@schematics/angular:component": { + "styleext": "scss" + } + }, + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/my-app", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + } + ] + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "my-app:build" + }, + "configurations": { + "production": { + "browserTarget": "my-app:build:production" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "my-app:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/test.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "src/karma.conf.js", + "styles": [ + "src/styles.scss" + ], + "scripts": [], + "assets": [ + "src/favicon.ico", + "src/assets" + ] + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": [ + "src/tsconfig.app.json", + "src/tsconfig.spec.json" + ], + "exclude": [ + "**/node_modules/**" + ] + } + } + } + } + } +} diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/ProjectSetProperties.json b/packages/angular_devkit/core/src/workspace/json/test/cases/ProjectSetProperties.json new file mode 100644 index 000000000000..7b8b3f85e5c4 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/ProjectSetProperties.json @@ -0,0 +1,110 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "my-app": { + "root": "", + "projectType": "application", + "prefix": "app", + "schematics": { + "@schematics/angular:component": { + "styleext": "scss" + } + }, + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/my-app", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + } + ] + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "my-app:build" + }, + "configurations": { + "production": { + "browserTarget": "my-app:build:production" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "my-app:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/test.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "src/karma.conf.js", + "styles": [ + "src/styles.scss" + ], + "scripts": [], + "assets": [ + "src/favicon.ico", + "src/assets" + ] + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": [ + "src/tsconfig.app.json", + "src/tsconfig.spec.json" + ], + "exclude": [ + "**/node_modules/**" + ] + } + } + }, + "sourceRoot": "src" + } + } +} diff --git a/packages/angular_devkit/core/src/workspace/json/test/cases/Retain.json b/packages/angular_devkit/core/src/workspace/json/test/cases/Retain.json new file mode 100644 index 000000000000..583b2da3598b --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/test/cases/Retain.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 10, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, +} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/json/utilities.ts b/packages/angular_devkit/core/src/workspace/json/utilities.ts new file mode 100644 index 000000000000..4285d1c84c04 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/utilities.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { JsonArray, JsonObject, JsonValue, isJsonObject } from '../../json'; + +export type ChangeListener = (path: string[], newValue: JsonValue | undefined) => void; + +type ChangeReporter = ( + path: string[], + target: JsonObject | JsonArray, + oldValue: JsonValue | undefined, + newValue: JsonValue | undefined, +) => void; + +// lib.es5 PropertyKey is string | number | symbol which doesn't overlap ProxyHandler PropertyKey which is string | symbol. +// See https://github.com/microsoft/TypeScript/issues/42894 +type ProxyPropertyKey = string | symbol; + +export function createVirtualAstObject( + root: JsonObject | JsonArray, + options: { + exclude?: string[]; + include?: string[]; + listener?: ChangeListener; + } = {}, +): T { + const reporter: ChangeReporter = (path, target, oldValue, newValue) => { + if (!options.listener) { + return; + } + + if (oldValue === newValue || JSON.stringify(oldValue) === JSON.stringify(newValue)) { + // same value + return; + } + + if (Array.isArray(target)) { + // For arrays we remove the index and update the entire value as keeping + // track of changes by indices can be rather complex. + options.listener(path.slice(0, -1), target); + } else { + options.listener(path, newValue); + } + }; + + return create( + Array.isArray(root) ? [...root] : { ...root }, + [], + reporter, + new Set(options.exclude), + options.include?.length ? new Set(options.include) : undefined, + ) as T; +} + +function create( + obj: JsonObject | JsonArray, + path: string[], + reporter: ChangeReporter, + excluded = new Set(), + included?: Set, +) { + return new Proxy(obj, { + getOwnPropertyDescriptor(target: {}, p: ProxyPropertyKey): PropertyDescriptor | undefined { + if (excluded.has(p) || (included && !included.has(p))) { + return undefined; + } + + return Reflect.getOwnPropertyDescriptor(target, p); + }, + has(target: {}, p: ProxyPropertyKey): boolean { + if (typeof p === 'symbol' || excluded.has(p)) { + return false; + } + + return Reflect.has(target, p); + }, + get(target: {}, p: ProxyPropertyKey): unknown { + if (excluded.has(p) || (included && !included.has(p))) { + return undefined; + } + + const value = Reflect.get(target, p); + if (typeof p === 'symbol') { + return value; + } + + if ((isJsonObject(value) && !(value instanceof Map)) || Array.isArray(value)) { + return create(value, [...path, p], reporter); + } else { + return value; + } + }, + set(target: {}, p: ProxyPropertyKey, value: unknown): boolean { + if (excluded.has(p) || (included && !included.has(p))) { + return false; + } + + if (value === undefined) { + // setting to undefined is equivalent to a delete. + return this.deleteProperty?.(target, p) ?? false; + } + + if (typeof p === 'symbol') { + return Reflect.set(target, p, value); + } + + const existingValue = getCurrentValue(target, p); + if (Reflect.set(target, p, value)) { + reporter([...path, p], target, existingValue, value as JsonValue); + + return true; + } + + return false; + }, + deleteProperty(target: {}, p: ProxyPropertyKey): boolean { + if (excluded.has(p)) { + return false; + } + + if (typeof p === 'symbol') { + return Reflect.deleteProperty(target, p); + } + + const existingValue = getCurrentValue(target, p); + if (Reflect.deleteProperty(target, p)) { + reporter([...path, p], target, existingValue, undefined); + + return true; + } + + return true; + }, + defineProperty(target: {}, p: ProxyPropertyKey, attributes: PropertyDescriptor): boolean { + if (typeof p === 'symbol') { + return Reflect.defineProperty(target, p, attributes); + } + + return false; + }, + ownKeys(target: {}): ProxyPropertyKey[] { + return Reflect.ownKeys(target).filter( + (p) => !excluded.has(p) && (!included || included.has(p)), + ); + }, + }); +} + +function getCurrentValue(target: object, property: string): JsonValue | undefined { + if (Array.isArray(target) && isFinite(+property)) { + return target[+property]; + } + + if (target && property in target) { + return (target as JsonObject)[property]; + } + + return undefined; +} diff --git a/packages/angular_devkit/core/src/workspace/json/writer.ts b/packages/angular_devkit/core/src/workspace/json/writer.ts new file mode 100644 index 000000000000..e2b117abbdb1 --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/writer.ts @@ -0,0 +1,192 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { applyEdits, modify } from 'jsonc-parser'; +import { EOL } from 'node:os'; +import { JsonObject, JsonValue } from '../../json'; +import { ProjectDefinition, TargetDefinition, WorkspaceDefinition } from '../definitions'; +import { WorkspaceHost } from '../host'; +import { + JsonChange, + JsonWorkspaceDefinition, + JsonWorkspaceMetadata, + JsonWorkspaceSymbol, +} from './metadata'; + +export async function writeJsonWorkspace( + workspace: WorkspaceDefinition, + host: WorkspaceHost, + path?: string, + options: { + schema?: string; + } = {}, +): Promise { + const metadata = (workspace as JsonWorkspaceDefinition)[JsonWorkspaceSymbol]; + + if (metadata) { + if (!metadata.hasChanges) { + return; + } + // update existing JSON workspace + const data = updateJsonWorkspace(metadata); + + return host.writeFile(path ?? metadata.filePath, data); + } else { + // serialize directly + if (!path) { + throw new Error('path option is required'); + } + + const obj = convertJsonWorkspace(workspace, options.schema); + const data = JSON.stringify(obj, null, 2); + + return host.writeFile(path, data); + } +} + +function convertJsonWorkspace(workspace: WorkspaceDefinition, schema?: string): JsonObject { + const obj = { + $schema: schema || './node_modules/@angular/cli/lib/config/schema.json', + version: 1, + ...workspace.extensions, + ...(isEmpty(workspace.projects) + ? {} + : { projects: convertJsonProjectCollection(workspace.projects) }), + }; + + return obj; +} + +function convertJsonProjectCollection( + collection: Iterable<[string, ProjectDefinition]>, +): JsonObject { + const projects = Object.create(null) as JsonObject; + + for (const [projectName, project] of collection) { + projects[projectName] = convertJsonProject(project); + } + + return projects; +} + +function convertJsonProject(project: ProjectDefinition): JsonObject { + let targets: JsonObject | undefined; + if (project.targets.size > 0) { + targets = Object.create(null) as JsonObject; + for (const [targetName, target] of project.targets) { + targets[targetName] = convertJsonTarget(target); + } + } + + const obj = { + ...project.extensions, + root: project.root, + ...(project.sourceRoot === undefined ? {} : { sourceRoot: project.sourceRoot }), + ...(project.prefix === undefined ? {} : { prefix: project.prefix }), + ...(targets === undefined ? {} : { architect: targets }), + }; + + return obj; +} + +function isEmpty(obj: object | undefined): boolean { + return obj === undefined || Object.keys(obj).length === 0; +} + +function convertJsonTarget(target: TargetDefinition): JsonObject { + return { + builder: target.builder, + ...(isEmpty(target.options) ? {} : { options: target.options as JsonObject }), + ...(isEmpty(target.configurations) + ? {} + : { configurations: target.configurations as JsonObject }), + ...(target.defaultConfiguration === undefined + ? {} + : { defaultConfiguration: target.defaultConfiguration }), + }; +} + +function convertJsonTargetCollection(collection: Iterable<[string, TargetDefinition]>): JsonObject { + const targets = Object.create(null) as JsonObject; + + for (const [projectName, target] of collection) { + targets[projectName] = convertJsonTarget(target); + } + + return targets; +} + +function normalizeValue( + value: JsonChange['value'] | undefined, + type: JsonChange['type'], +): JsonValue | undefined { + if (value === undefined) { + return undefined; + } + + switch (type) { + case 'project': + return convertJsonProject(value as ProjectDefinition); + case 'projectcollection': { + const projects = convertJsonProjectCollection(value as Iterable<[string, ProjectDefinition]>); + + return isEmpty(projects) ? undefined : projects; + } + case 'target': + return convertJsonTarget(value as TargetDefinition); + case 'targetcollection': { + const targets = convertJsonTargetCollection(value as Iterable<[string, TargetDefinition]>); + + return isEmpty(targets) ? undefined : targets; + } + default: + return value as JsonValue; + } +} + +function updateJsonWorkspace(metadata: JsonWorkspaceMetadata): string { + let { raw: content } = metadata; + const { changes, hasLegacyTargetsName } = metadata; + + for (const { jsonPath, value, type } of changes.values()) { + // Determine which key to use if (architect or targets) + if (hasLegacyTargetsName && jsonPath[2] === 'targets') { + jsonPath[2] = 'architect'; + } + + // TODO: `modify` re-parses the content every time. + // See: https://github.com/microsoft/node-jsonc-parser/blob/35d94cd71bd48f9784453b2439262c938e21d49b/src/impl/edit.ts#L18 + // Ideally this should accept a string or an AST to avoid the potentially expensive repeat parsing operation. + const edits = modify(content, jsonPath, normalizeValue(value, type), { + formattingOptions: { + insertSpaces: true, + tabSize: 2, + eol: getEOL(content), + }, + }); + + content = applyEdits(content, edits); + } + + return content; +} + +function getEOL(content: string): string { + const CRLF = '\r\n'; + const LF = '\n'; + 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_devkit/core/src/workspace/json/writer_spec.ts b/packages/angular_devkit/core/src/workspace/json/writer_spec.ts new file mode 100644 index 000000000000..e0d4a055147a --- /dev/null +++ b/packages/angular_devkit/core/src/workspace/json/writer_spec.ts @@ -0,0 +1,605 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { JsonArray, JsonObject } from '../../json'; +import { stripIndent } from '../../utils/literals'; +import { ProjectDefinitionCollection, WorkspaceDefinition } from '../definitions'; +import { readJsonWorkspace } from './reader'; +import { writeJsonWorkspace } from './writer'; + +const basicFile = stripIndent` +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, +}`; + +const representativeFile = readFileSync(require.resolve(__dirname + '/test/angular.json'), 'utf8'); + +function createTestCaseHost(inputData = '') { + const host = { + async readFile() { + return inputData; + }, + async writeFile(path: string, data: string) { + try { + const testCase = readFileSync( + require.resolve(join(__dirname, 'test', 'cases', path) + '.json'), + 'utf8', + ); + expect(data.trim()).toEqual(testCase.trim()); + } catch (e) { + fail(`Unable to load test case '${path}': ${e instanceof Error ? e.message : e}`); + } + }, + async isFile() { + return true; + }, + async isDirectory() { + return true; + }, + }; + + return host; +} + +describe('writeJsonWorkpaceFile', () => { + it('does not modify a file without changes', async () => { + const host = { + async readFile() { + return representativeFile; + }, + async writeFile() { + fail(); + }, + async isFile() { + return true; + }, + async isDirectory() { + return true; + }, + }; + + const workspace = await readJsonWorkspace('angular.json', host); + await writeJsonWorkspace(workspace, host); + }); + + it('writes an empty workspace', async () => { + const workspace: WorkspaceDefinition = { + extensions: {}, + projects: new ProjectDefinitionCollection(), + }; + await writeJsonWorkspace(workspace, createTestCaseHost(), 'Empty'); + }); + + it('writes new workspace with extensions', async () => { + const workspace: WorkspaceDefinition = { + extensions: { + newProjectRoot: 'projects', + }, + projects: new ProjectDefinitionCollection(), + }; + await writeJsonWorkspace(workspace, createTestCaseHost(), 'Extensions1'); + + workspace.extensions['schematics'] = { + '@schematics/angular:component': { prefix: 'app' }, + }; + await writeJsonWorkspace(workspace, createTestCaseHost(), 'Extensions2'); + }); + + it('writes new workspace with an empty project', async () => { + const workspace: WorkspaceDefinition = { + extensions: {}, + projects: new ProjectDefinitionCollection(), + }; + + workspace.projects.add({ + name: 'my-app', + root: 'projects/my-app', + }); + + await writeJsonWorkspace(workspace, createTestCaseHost(), 'ProjectEmpty'); + }); + + it('writes new workspace with a full project', async () => { + const workspace: WorkspaceDefinition = { + extensions: {}, + projects: new ProjectDefinitionCollection(), + }; + + workspace.projects.add({ + name: 'my-app', + root: 'projects/my-app', + targets: { + build: { + builder: '@angular-devkit/build-angular:browser', + options: { + outputPath: `dist/my-app`, + index: `projects/my-app/src/index.html`, + main: `projects/my-app/src/main.ts`, + polyfills: `projects/my-app/src/polyfills.ts`, + tsConfig: `projects/my-app/tsconfig.app.json`, + assets: ['projects/my-app/src/favicon.ico', 'projects/my-app/src/assets'], + styles: [`projects/my-app/src/styles.scss`], + scripts: [], + es5BrowserSupport: true, + }, + configurations: { + production: { + fileReplacements: [ + { + replace: `projects/my-app/src/environments/environment.ts`, + with: `projects/my-app/src/environments/environment.prod.ts`, + }, + ], + optimization: true, + outputHashing: 'all', + sourceMap: false, + extractCss: true, + namedChunks: false, + aot: true, + extractLicenses: true, + vendorChunk: false, + buildOptimizer: true, + budgets: [ + { + type: 'initial', + maximumWarning: '2mb', + maximumError: '5mb', + }, + ], + }, + }, + }, + serve: { + builder: '@angular-devkit/build-angular:dev-server', + options: { + browserTarget: `my-app:build`, + }, + configurations: { + production: { + browserTarget: `my-app:build:production`, + }, + }, + }, + }, + }); + + await writeJsonWorkspace(workspace, createTestCaseHost(), 'ProjectFull'); + }); + + it('retains comments and formatting when modifying the workspace', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + workspace.extensions['x-baz'] = 10; + + await writeJsonWorkspace(workspace, host, 'Retain'); + }); + + it('adds a project to workspace without any projects', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + workspace.projects.add({ + name: 'new', + root: 'src7', + }); + + await writeJsonWorkspace(workspace, host, 'AddProject1'); + }); + + it('adds a project to workspace with existing projects', async () => { + const host = createTestCaseHost(representativeFile); + const workspace = await readJsonWorkspace('', host); + + workspace.projects.add({ + name: 'new', + root: 'src', + }); + + await writeJsonWorkspace(workspace, host, 'AddProject2'); + }); + + it('adds a project to workspace with existing projects when name is number like', async () => { + const host = createTestCaseHost(representativeFile); + const workspace = await readJsonWorkspace('', host); + + workspace.projects.add({ + name: '1', + root: 'src', + }); + + await writeJsonWorkspace(workspace, host, 'AddProject3'); + }); + + it('adds a project with targets', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + workspace.projects.add({ + name: 'new', + root: 'src', + targets: { + build: { + builder: 'build-builder', + options: { one: 1, two: false }, + configurations: { + staging: { + two: true, + }, + }, + }, + }, + }); + + await writeJsonWorkspace(workspace, host, 'AddProjectWithTargets'); + }); + + it('adds a project with targets using reference to workspace', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + workspace.projects.add({ + name: 'new', + root: 'src', + }); + + const project = workspace.projects.get('new'); + if (!project) { + fail('project is missing'); + + return; + } + + project.targets.add({ + name: 'build', + builder: 'build-builder', + options: { one: 1, two: false }, + configurations: { + staging: { + two: true, + }, + }, + }); + + // This should be the same as adding them in the project add call + await writeJsonWorkspace(workspace, host, 'AddProjectWithTargets'); + }); + + it("modifies a project's properties", async () => { + const host = createTestCaseHost(representativeFile); + const workspace = await readJsonWorkspace('', host); + + const project = workspace.projects.get('my-app'); + if (!project) { + fail('project is missing'); + + return; + } + + project.root = 'src'; + + await writeJsonWorkspace(workspace, host, 'ProjectModifyProperties'); + }); + + it("sets a project's properties", async () => { + const host = createTestCaseHost(representativeFile); + const workspace = await readJsonWorkspace('', host); + + const project = workspace.projects.get('my-app'); + if (!project) { + fail('project is missing'); + + return; + } + + project.sourceRoot = 'src'; + + await writeJsonWorkspace(workspace, host, 'ProjectSetProperties'); + }); + + it('adds a target to an existing project', async () => { + const host = createTestCaseHost(representativeFile); + const workspace = await readJsonWorkspace('', host); + + const project = workspace.projects.get('my-app'); + if (!project) { + fail('project is missing'); + + return; + } + + project.targets.add({ + name: 'new', + builder: 'new-builder', + }); + + await writeJsonWorkspace(workspace, host, 'ProjectAddTarget'); + }); + + it('deletes a target from an existing project', async () => { + const host = createTestCaseHost(representativeFile); + const workspace = await readJsonWorkspace('', host); + + const project = workspace.projects.get('my-app'); + if (!project) { + fail('project is missing'); + + return; + } + + project.targets.delete('extract-i18n'); + + await writeJsonWorkspace(workspace, host, 'ProjectDeleteTarget'); + }); + + it('supports adding an empty array', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + workspace.extensions['x-array'] = []; + + await writeJsonWorkspace(workspace, host, 'AddArrayEmpty'); + }); + + it('supports adding an array with values', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + workspace.extensions['x-array'] = [5, 'a', false, null, true, 9.9]; + + await writeJsonWorkspace(workspace, host, 'ArrayValues'); + }); + + it('supports adding an empty array then pushing as an extension', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + workspace.extensions['x-array'] = []; + (workspace.extensions['x-array'] as string[]).push('value'); + + await writeJsonWorkspace(workspace, host, 'AddArrayPush'); + }); + + it('supports pushing to an existing array', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + const array = (workspace.extensions['x-foo'] as JsonObject)['is'] as JsonArray; + array.push('value'); + + await writeJsonWorkspace(workspace, host, 'ArrayPush'); + }); + + it('supports unshifting to an existing array', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + const array = (workspace.extensions['x-foo'] as JsonObject)['is'] as JsonArray; + array.unshift('value'); + + await writeJsonWorkspace(workspace, host, 'ArrayUnshift'); + }); + + it('supports shifting from an existing array', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + const array = (workspace.extensions['x-foo'] as JsonObject)['is'] as JsonArray; + array.shift(); + + await writeJsonWorkspace(workspace, host, 'ArrayShift'); + }); + + it('supports splicing an existing array without new values', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + const array = (workspace.extensions['x-foo'] as JsonObject)['is'] as JsonArray; + array.splice(2, 1); + + await writeJsonWorkspace(workspace, host, 'ArraySplice1'); + }); + + it('supports splicing an existing array with new values', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + const array = (workspace.extensions['x-foo'] as JsonObject)['is'] as JsonArray; + array.splice(2, 0, 'value1', 'value2'); + + await writeJsonWorkspace(workspace, host, 'ArraySplice2'); + }); + + it('supports popping from an existing array', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + const array = (workspace.extensions['x-foo'] as JsonObject)['is'] as JsonArray; + array.pop(); + + await writeJsonWorkspace(workspace, host, 'ArrayPop'); + }); + + it('supports sorting from an existing array', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + const array = (workspace.extensions['x-foo'] as JsonObject)['is'] as JsonArray; + array.sort(); + + await writeJsonWorkspace(workspace, host, 'ArraySort'); + }); + + it('replaces a value at zero index from an existing array', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + const array = (workspace.extensions['x-foo'] as JsonObject)['is'] as JsonArray; + array[0] = 'value'; + + await writeJsonWorkspace(workspace, host, 'ArrayIndexZero'); + }); + + it('replaces a value at inner index from an existing array', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + const array = (workspace.extensions['x-foo'] as JsonObject)['is'] as JsonArray; + array[1] = 'value'; + + await writeJsonWorkspace(workspace, host, 'ArrayIndexInner'); + }); + + it('replaces a value at last index from an existing array', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + const array = (workspace.extensions['x-foo'] as JsonObject)['is'] as JsonArray; + array[array.length - 1] = 'value'; + + await writeJsonWorkspace(workspace, host, 'ArrayIndexLast'); + }); + + it('deletes a value at zero index from an existing array', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + const array = (workspace.extensions['x-foo'] as JsonObject)['is'] as JsonArray; + array.splice(0, 1); + + await writeJsonWorkspace(workspace, host, 'ArrayDeleteZero'); + }); + + it('deletes a value at inner index from an existing array', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + const array = (workspace.extensions['x-foo'] as JsonObject)['is'] as JsonArray; + array.splice(1, 1); + + await writeJsonWorkspace(workspace, host, 'ArrayDeleteInner'); + }); + + it('deletes and then adds a value at inner index from an existing array', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + const array = (workspace.extensions['x-foo'] as JsonObject)['is'] as JsonArray; + array.splice(1, 1); + array.splice(1, 0, 'new'); + + await writeJsonWorkspace(workspace, host, 'ArrayDeleteInnerAdd'); + }); + + it('deletes a value at last index from an existing array', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + const array = (workspace.extensions['x-foo'] as JsonObject)['is'] as JsonArray; + array.splice(array.length - 1, 1); + + await writeJsonWorkspace(workspace, host, 'ArrayDeleteLast'); + }); + + it('deletes and then adds a value at last index from an existing array', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + const array = (workspace.extensions['x-foo'] as JsonObject)['is'] as JsonArray; + array.splice(array.length - 1, 1); + array.push('new'); + + await writeJsonWorkspace(workspace, host, 'ArrayDeleteLastAdd'); + }); + + it('replaces an existing array', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + (workspace.extensions['x-foo'] as JsonObject)['is'] = ['value']; + + await writeJsonWorkspace(workspace, host, 'ArrayReplace1'); + }); + + it('replaces an existing array with an empty array', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + (workspace.extensions['x-foo'] as JsonObject)['is'] = []; + + await writeJsonWorkspace(workspace, host, 'ArrayReplace2'); + }); + + it('replaces an existing object with a new object', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + workspace.extensions['x-foo'] = { replacement: true }; + + await writeJsonWorkspace(workspace, host, 'ObjectReplace1'); + }); + + it('replaces an existing object with an empty object', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + workspace.extensions['x-foo'] = {}; + + await writeJsonWorkspace(workspace, host, 'ObjectReplace2'); + }); + + it('replaces an existing object with a different value type', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + workspace.extensions['x-foo'] = null; + + await writeJsonWorkspace(workspace, host, 'ObjectReplace3'); + }); + + it('removes a property when property value is set to undefined', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + workspace.extensions['x-baz'] = undefined; + + await writeJsonWorkspace(workspace, host, 'ObjectRemove'); + }); + + it('removes a property when using delete operator', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + delete workspace.extensions['x-baz']; + + await writeJsonWorkspace(workspace, host, 'ObjectRemove'); + }); + + it('removes multiple properties when using delete operator', async () => { + const host = createTestCaseHost(basicFile); + const workspace = await readJsonWorkspace('', host); + + delete workspace.extensions['x-baz']; + delete workspace.extensions.schematics; + + await writeJsonWorkspace(workspace, host, 'ObjectRemoveMultiple'); + }); +}); diff --git a/packages/angular_devkit/core/src/workspace/test/test-workspace.json b/packages/angular_devkit/core/src/workspace/test/test-workspace.json deleted file mode 100644 index bf01c8c02db9..000000000000 --- a/packages/angular_devkit/core/src/workspace/test/test-workspace.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "version": 1, - "newProjectRoot": "./projects", - "defaultProject": "app", - "cli": { - "$globalOverride": "${HOME}/.angular-cli.json", - "schematics": { - "defaultCollection": "@schematics/angular" - }, - "warnings": { - "showDeprecation": false - } - }, - "schematics": { - "@schematics/angular": { - "*": { - "skipImport": true, - "packageManager": "yarn" - }, - "application": { - "spec": false - } - } - }, - "architect": {}, - "projects": { - "app": { - "root": "projects/app", - "projectType": "application", - "cli": {}, - "schematics": { - "@schematics/angular": { - "*": { - "spec": false - } - } - }, - "architect": { - "build": { - "builder": "@angular-devkit/build-angular:browser", - "transforms": [ - { - "plugin": "@angular-devkit/architect-transforms:replacement", - "file": "environments/environment.ts", - "configurations": { - "production": "environments/environment.prod.ts" - } - } - ], - "options": { - "outputPath": "../dist", - "index": "index.html", - "main": "main.ts", - "polyfills": "polyfills.ts", - "tsConfig": "tsconfig.app.json", - "progress": false - }, - "configurations": { - "production": { - "optimization": true, - "outputHashing": "all", - "sourceMap": false, - "extractCss": true, - "namedChunks": false, - "aot": true, - "extractLicenses": true, - "vendorChunk": false, - "buildOptimizer": true - } - } - } - } - } - } -} \ No newline at end of file diff --git a/packages/angular_devkit/core/src/workspace/workspace-schema.json b/packages/angular_devkit/core/src/workspace/workspace-schema.json deleted file mode 100644 index a030a6753d55..000000000000 --- a/packages/angular_devkit/core/src/workspace/workspace-schema.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "id": "AngularDevkitWorkspaceSchema", - "title": "Angular Devkit Workspace Schema for validating workspace JSON.", - "type": "object", - "properties": { - "$schema": { - "type": "string", - "description": "Link to schema." - }, - "version": { - "type": "number", - "description": "Workspace Schema version." - }, - "newProjectRoot": { - "type": "string", - "description": "New project root.", - "default": "./" - }, - "defaultProject": { - "type": "string", - "description": "The default project." - }, - "cli": { - "$ref": "#/definitions/tool", - "default": {} - }, - "schematics": { - "$ref": "#/definitions/tool", - "default": {} - }, - "architect": { - "$ref": "#/definitions/tool", - "default": {} - }, - "targets": { - "$ref": "#/definitions/tool", - "default": {} - }, - "projects": { - "type": "object", - "description": "A map of project names to project options.", - "additionalProperties": { - "$ref": "#/definitions/project" - }, - "default": {} - } - }, - "additionalProperties": false, - "required": [ - "version" - ], - "definitions": { - "project": { - "type": "object", - "description": "Project options.", - "properties": { - "projectType": { - "type": "string", - "description": "Project type.", - "enum": [ - "application", - "library" - ] - }, - "root": { - "type": "string", - "description": "Root of the project sourcefiles." - }, - "sourceRoot": { - "type": "string", - "description": "The root of the source files, assets and index.html file structure." - }, - "prefix": { - "type": "string", - "description": "The prefix to apply to generated selectors." - }, - "cli": { - "$ref": "#/definitions/tool", - "default": {} - }, - "schematics": { - "$ref": "#/definitions/tool", - "default": {} - }, - "architect": { - "$ref": "#/definitions/tool", - "default": {} - }, - "targets": { - "$ref": "#/definitions/tool", - "default": {} - } - }, - "additionalProperties": false, - "required": [ - "root", - "projectType" - ] - }, - "tool": { - "type": "object", - "description": "Tool options.", - "properties": { - "$schema": { - "type": "string", - "description": "Link to schema." - } - }, - "additionalProperties": true - } - } -} diff --git a/packages/angular_devkit/core/src/workspace/workspace-schema.ts b/packages/angular_devkit/core/src/workspace/workspace-schema.ts deleted file mode 100644 index f10dbc18a688..000000000000 --- a/packages/angular_devkit/core/src/workspace/workspace-schema.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable -export interface WorkspaceSchema { - /** - * Link to schema. - */ - $schema?: string; - /** - * Workspace Schema version. - */ - version: number; - /** - * New project root. - */ - newProjectRoot?: string; - /** - * The default project. - */ - defaultProject?: string; - /** - * Tool options. - */ - cli?: WorkspaceTool; - /** - * Tool options. - */ - schematics?: WorkspaceTool; - /** - * Tool options. - */ - architect?: WorkspaceTool; - /** - * Tool options. - */ - targets?: WorkspaceTool; - /** - * A map of project names to project options. - */ - projects: { - [k: string]: WorkspaceProject; - }; -} -/** - * Project options. - */ -export interface WorkspaceProject { - /** - * Project type. - */ - projectType: "application" | "library"; - /** - * Root of the project sourcefiles. - */ - root: string; - /** - * The root of the source files, assets and index.html file structure. - */ - sourceRoot?: string; - /** - * The prefix to apply to generated selectors. - */ - prefix: string; - /** - * Tool options. - */ - cli?: WorkspaceTool; - /** - * Tool options. - */ - schematics?: WorkspaceTool; - /** - * Tool options. - */ - architect?: WorkspaceTool; - /** - * Tool options. - */ - targets?: WorkspaceTool; -} -/** - * Architect options. - */ -export interface WorkspaceTool { - /** - * Link to schema. - */ - $schema?: string; - [k: string]: any; -} diff --git a/packages/angular_devkit/core/src/workspace/workspace.ts b/packages/angular_devkit/core/src/workspace/workspace.ts deleted file mode 100644 index d03bdb42c898..000000000000 --- a/packages/angular_devkit/core/src/workspace/workspace.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * @license - * 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 - */ - -import { Observable, of, throwError } from 'rxjs'; -import { concatMap, map, tap } from 'rxjs/operators'; -import { BaseException } from '../exception'; -import { - JsonObject, - JsonParseMode, - parseJson, - schema, -} from '../json'; -import { - Path, - isAbsolute, - join, - normalize, - relative, - resolve, - virtualFs, -} from '../virtual-fs'; -import { WorkspaceProject, WorkspaceSchema, WorkspaceTool } from './workspace-schema'; - - -export class ProjectNotFoundException extends BaseException { - constructor(name: string) { - super(`Project '${name}' could not be found in workspace.`); - } -} - -export class WorkspaceToolNotFoundException extends BaseException { - constructor(name: string) { - super(`Tool ${name} could not be found in workspace.`); - } -} - -export class ProjectToolNotFoundException extends BaseException { - constructor(name: string) { - super(`Tool ${name} could not be found in project.`); - } -} - -export class WorkspaceNotYetLoadedException extends BaseException { - constructor() { super(`Workspace needs to be loaded before it is used.`); } -} - -export class AmbiguousProjectPathException extends BaseException { - constructor(public readonly path: Path, public readonly projects: ReadonlyArray) { - super(`Current active project is ambiguous (${projects.join(',')}) using path: '${path}'`); - } -} - -export class Workspace { - private readonly _workspaceSchemaPath = normalize(require.resolve('./workspace-schema.json')); - private _workspaceSchema: JsonObject; - private _workspace: WorkspaceSchema; - private _registry: schema.CoreSchemaRegistry; - - constructor(private _root: Path, private _host: virtualFs.Host<{}>) { - this._registry = new schema.CoreSchemaRegistry(); - this._registry.addPostTransform(schema.transforms.addUndefinedDefaults); - } - - loadWorkspaceFromJson(json: {}) { - return this._loadWorkspaceSchema().pipe( - concatMap((workspaceSchema) => this.validateAgainstSchema(json, workspaceSchema)), - tap((validatedWorkspace: WorkspaceSchema) => this._workspace = validatedWorkspace), - map(() => this), - ); - } - - loadWorkspaceFromHost(workspacePath: Path) { - return this._loadWorkspaceSchema().pipe( - concatMap(() => this._loadJsonFile(join(this._root, workspacePath))), - concatMap(json => this.loadWorkspaceFromJson(json)), - ); - } - - private _loadWorkspaceSchema() { - if (this._workspaceSchema) { - return of(this._workspaceSchema); - } else { - return this._loadJsonFile(this._workspaceSchemaPath).pipe( - tap((workspaceSchema) => this._workspaceSchema = workspaceSchema), - ); - } - } - - private _assertLoaded() { - if (!this._workspace) { - throw new WorkspaceNotYetLoadedException(); - } - } - - get root() { - return this._root; - } - - get host() { - return this._host; - } - - get version() { - this._assertLoaded(); - - return this._workspace.version; - } - - get newProjectRoot() { - this._assertLoaded(); - - return this._workspace.newProjectRoot; - } - - listProjectNames(): string[] { - return Object.keys(this._workspace.projects); - } - - getProject(projectName: string): WorkspaceProject { - this._assertLoaded(); - - const workspaceProject = this._workspace.projects[projectName]; - - if (!workspaceProject) { - throw new ProjectNotFoundException(projectName); - } - - // Return only the project properties, and remove the tools. - const workspaceProjectClone = {...workspaceProject}; - delete workspaceProjectClone['cli']; - delete workspaceProjectClone['schematics']; - delete workspaceProjectClone['architect']; - delete workspaceProjectClone['targets']; - - return workspaceProjectClone; - } - - getDefaultProjectName(): string | null { - this._assertLoaded(); - - if (this._workspace.defaultProject) { - // If there is a default project name, return it. - return this._workspace.defaultProject; - } else if (this.listProjectNames().length === 1) { - // If there is only one project, return that one. - return this.listProjectNames()[0]; - } - - // Otherwise return null. - return null; - } - - getProjectByPath(path: Path): string | null { - this._assertLoaded(); - - const projectNames = this.listProjectNames(); - if (projectNames.length === 1) { - return projectNames[0]; - } - - const isInside = (base: Path, potential: Path): boolean => { - const absoluteBase = resolve(this.root, base); - const absolutePotential = resolve(this.root, potential); - const relativePotential = relative(absoluteBase, absolutePotential); - if (!relativePotential.startsWith('..') && !isAbsolute(relativePotential)) { - return true; - } - - return false; - }; - - const projects = this.listProjectNames() - .map(name => [this.getProject(name).root, name] as [Path, string]) - .filter(tuple => isInside(tuple[0], path)) - // Sort tuples by depth, with the deeper ones first. Since the first member is a path and - // we filtered all invalid paths, the longest will be the deepest (and in case of equality - // the sort is stable and the first declared project will win). - .sort((a, b) => b[0].length - a[0].length); - - if (projects.length === 0) { - return null; - } else if (projects.length > 1) { - const found = new Set(); - const sameRoots = projects.filter(v => { - if (!found.has(v[0])) { - found.add(v[0]); - - return false; - } - - return true; - }); - if (sameRoots.length > 0) { - throw new AmbiguousProjectPathException(path, sameRoots.map(v => v[1])); - } - } - - return projects[0][1]; - } - - getCli() { - return this._getTool('cli'); - } - - getSchematics() { - return this._getTool('schematics'); - } - - getTargets() { - return this._getTool('targets'); - } - - getProjectCli(projectName: string) { - return this._getProjectTool(projectName, 'cli'); - } - - getProjectSchematics(projectName: string) { - return this._getProjectTool(projectName, 'schematics'); - } - - getProjectTargets(projectName: string) { - return this._getProjectTool(projectName, 'targets'); - } - - private _getTool(toolName: 'cli' | 'schematics' | 'targets'): WorkspaceTool { - this._assertLoaded(); - - let workspaceTool = this._workspace[toolName]; - - // Try falling back to 'architect' if 'targets' is not there or is empty. - if ((!workspaceTool || Object.keys(workspaceTool).length === 0) - && toolName === 'targets' - && this._workspace['architect']) { - workspaceTool = this._workspace['architect']; - } - - if (!workspaceTool) { - throw new WorkspaceToolNotFoundException(toolName); - } - - return workspaceTool; - } - - private _getProjectTool( - projectName: string, toolName: 'cli' | 'schematics' | 'targets', - ): WorkspaceTool { - this._assertLoaded(); - - const workspaceProject = this._workspace.projects[projectName]; - - if (!workspaceProject) { - throw new ProjectNotFoundException(projectName); - } - - let projectTool = workspaceProject[toolName]; - - // Try falling back to 'architect' if 'targets' is not there or is empty. - if ((!projectTool || Object.keys(projectTool).length === 0) - && workspaceProject['architect'] - && toolName === 'targets') { - projectTool = workspaceProject['architect']; - } - - if (!projectTool) { - throw new ProjectToolNotFoundException(toolName); - } - - return projectTool; - } - - // TODO: add transforms to resolve paths. - validateAgainstSchema(contentJson: {}, schemaJson: JsonObject): Observable { - // JSON validation modifies the content, so we validate a copy of it instead. - const contentJsonCopy = JSON.parse(JSON.stringify(contentJson)); - - return this._registry.compile(schemaJson).pipe( - concatMap(validator => validator(contentJsonCopy)), - concatMap(validatorResult => { - if (validatorResult.success) { - return of(contentJsonCopy as T); - } else { - return throwError(new schema.SchemaValidationException(validatorResult.errors)); - } - }), - ); - } - - private _loadJsonFile(path: Path): Observable { - return this._host.read(normalize(path)).pipe( - map(buffer => virtualFs.fileBufferToString(buffer)), - map(str => parseJson(str, JsonParseMode.Loose) as {} as JsonObject), - ); - } -} diff --git a/packages/angular_devkit/core/src/workspace/workspace_spec.ts b/packages/angular_devkit/core/src/workspace/workspace_spec.ts deleted file mode 100644 index 534236bf7a23..000000000000 --- a/packages/angular_devkit/core/src/workspace/workspace_spec.ts +++ /dev/null @@ -1,311 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable:no-big-function - -import { tap } from 'rxjs/operators'; -import { schema } from '..'; -import { NodeJsSyncHost } from '../../node'; -import { AdditionalPropertiesValidatorError } from '../json/schema/interface'; -import { dirname, join, normalize } from '../virtual-fs'; -import { - ProjectNotFoundException, - Workspace, - WorkspaceNotYetLoadedException, -} from './workspace'; -import { WorkspaceProject, WorkspaceSchema, WorkspaceTool } from './workspace-schema'; - - -describe('Workspace', () => { - const host = new NodeJsSyncHost(); - const root = normalize(__dirname); - // The content of this JSON object should be kept in sync with the path below: - // tests/angular_devkit/core/workspace/angular-workspace.json - const workspaceJson: WorkspaceSchema = { - version: 1, - newProjectRoot: './projects', - defaultProject: 'app', - cli: { - '$globalOverride': '${HOME}/.angular-cli.json', - 'schematics': { - 'defaultCollection': '@schematics/angular', - }, - 'warnings': { - 'showDeprecation': false, - }, - }, - schematics: { - '@schematics/angular': { - '*': { - skipImport: true, - 'packageManager': 'yarn', - }, - 'application': { - spec: false, - }, - }, - }, - targets: {}, - projects: { - app: { - root: 'projects/app', - sourceRoot: 'projects/app/src', - projectType: 'application', - prefix: 'app', - cli: {}, - schematics: { - '@schematics/angular': { - '*': { - spec: false, - }, - }, - }, - targets: { - build: { - builder: '@angular-devkit/build-angular:browser', - transforms: [ - { - plugin: '@angular-devkit/architect-transforms:replacement', - file: 'environments/environment.ts', - configurations: { - production: 'environments/environment.prod.ts', - }, - }, - ], - options: { - outputPath: '../dist', - index: 'index.html', - main: 'main.ts', - polyfills: 'polyfills.ts', - tsConfig: 'tsconfig.app.json', - progress: false, - }, - configurations: { - production: { - optimize: true, - outputHashing: 'all', - sourceMap: false, - extractCss: true, - namedChunks: false, - aot: true, - extractLicenses: true, - vendorChunk: false, - buildOptimizer: true, - }, - }, - }, - }, - }, - }, - }; - // Tools should not be returned when getting a project. - const appProject = { ...workspaceJson.projects['app'] }; - delete appProject['cli']; - delete appProject['schematics']; - delete appProject['targets']; - - it('loads workspace from json', (done) => { - const workspace = new Workspace(root, host); - workspace.loadWorkspaceFromJson(workspaceJson).pipe( - tap((ws) => expect(ws.getProject('app').root).toEqual(workspaceJson.projects['app'].root)), - ).toPromise().then(done, done.fail); - }); - - it('loads workspace from host', (done) => { - const workspacePath = require.resolve('./test/test-workspace.json'); - const workspaceRoot = dirname(normalize(workspacePath)); - const workspace = new Workspace(workspaceRoot, host); - workspace.loadWorkspaceFromHost(normalize('test-workspace.json')).pipe( - tap((ws) => expect(ws.getProject('app').root).toEqual(workspaceJson.projects['app'].root)), - ).toPromise().then(done, done.fail); - }); - - it('errors when workspace fails validation', (done) => { - const workspace = new Workspace(root, host); - workspace.loadWorkspaceFromJson({ foo: 'bar' }) - .toPromise().then(() => done.fail, (err) => { - const validationErrors = [{ - keyword: 'additionalProperties', - dataPath: '', - schemaPath: '#/additionalProperties', - params: { additionalProperty: 'foo' }, - message: 'should NOT have additional properties', - } as AdditionalPropertiesValidatorError]; - expect(err).toEqual(new schema.SchemaValidationException(validationErrors)); - done(); - }); - }); - - it('throws when getting information before workspace is loaded', () => { - const workspace = new Workspace(root, host); - expect(() => workspace.version).toThrow(new WorkspaceNotYetLoadedException()); - }); - - it('throws when getting workspace tool before workspace is loaded', () => { - const workspace = new Workspace(root, host); - expect(() => workspace.getCli()).toThrow(new WorkspaceNotYetLoadedException()); - }); - - it('gets workspace root', () => { - const workspace = new Workspace(root, host); - expect(workspace.root).toBe(root); - }); - - it('gets workspace host', () => { - const workspace = new Workspace(root, host); - expect(workspace.host).toBe(host); - }); - - it('gets workspace version', (done) => { - const workspace = new Workspace(root, host); - workspace.loadWorkspaceFromJson(workspaceJson).pipe( - tap((ws) => expect(ws.version).toEqual(workspaceJson.version)), - ).toPromise().then(done, done.fail); - }); - - it('gets workspace new project root', (done) => { - const workspace = new Workspace(root, host); - workspace.loadWorkspaceFromJson(workspaceJson).pipe( - tap((ws) => expect(ws.newProjectRoot).toEqual(workspaceJson.newProjectRoot)), - ).toPromise().then(done, done.fail); - }); - - it('lists project names', (done) => { - const workspace = new Workspace(root, host); - workspace.loadWorkspaceFromJson(workspaceJson).pipe( - tap((ws) => expect(ws.listProjectNames()).toEqual(['app'])), - ).toPromise().then(done, done.fail); - }); - - it('gets project by name', (done) => { - const workspace = new Workspace(root, host); - workspace.loadWorkspaceFromJson(workspaceJson).pipe( - tap((ws) => expect(ws.getProject('app')).toEqual(appProject)), - ).toPromise().then(done, done.fail); - }); - - it('throws on missing project', (done) => { - const workspace = new Workspace(root, host); - workspace.loadWorkspaceFromJson(workspaceJson).pipe( - tap((ws) => expect(() => ws.getProject('abc')).toThrow(new ProjectNotFoundException('abc'))), - ).toPromise().then(done, done.fail); - }); - - it('gets default project', (done) => { - const workspace = new Workspace(root, host); - workspace.loadWorkspaceFromJson(workspaceJson).pipe( - tap((ws) => expect(ws.getDefaultProjectName()).toEqual('app')), - ).toPromise().then(done, done.fail); - }); - - it('gets default project when there is a single one', (done) => { - const customWorkspaceJson = { ...workspaceJson, defaultProject: undefined }; - const workspace = new Workspace(root, host); - workspace.loadWorkspaceFromJson(customWorkspaceJson).pipe( - tap((ws) => expect(ws.getDefaultProjectName()).toEqual('app')), - ).toPromise().then(done, done.fail); - }); - - it('gets default project returns null when there is none', (done) => { - const customWorkspaceJson = { ...workspaceJson, defaultProject: undefined, projects: {} }; - const workspace = new Workspace(root, host); - workspace.loadWorkspaceFromJson(customWorkspaceJson).pipe( - tap((ws) => expect(ws.getDefaultProjectName()).toEqual(null)), - ).toPromise().then(done, done.fail); - }); - - it('gets project by path', (done) => { - const workspace = new Workspace(root, host); - workspace.loadWorkspaceFromJson(workspaceJson).pipe( - tap((ws) => expect(ws.getProjectByPath(ws.root)).toEqual('app')), - ).toPromise().then(done, done.fail); - }); - - it('gets closest project by path', (done) => { - const app = workspaceJson.projects['app']; - const anotherAppRoot = join(normalize(app.root), 'folder'); - const customWorkspaceJson = { ...workspaceJson, projects: { - 'app': app, - 'another-app': { ...app, root: anotherAppRoot}, - } }; - const workspace = new Workspace(root, host); - workspace.loadWorkspaceFromJson(customWorkspaceJson).pipe( - tap((ws) => expect(ws.getProjectByPath(anotherAppRoot)).toEqual('another-app')), - ).toPromise().then(done, done.fail); - }); - - it('gets workspace cli', (done) => { - const workspace = new Workspace(root, host); - workspace.loadWorkspaceFromJson(workspaceJson).pipe( - tap((ws) => expect(ws.getCli()).toEqual(workspaceJson.cli as WorkspaceTool)), - ).toPromise().then(done, done.fail); - }); - - it('gets workspace schematics', (done) => { - const workspace = new Workspace(root, host); - workspace.loadWorkspaceFromJson(workspaceJson).pipe( - tap((ws) => expect(ws.getSchematics()).toEqual(workspaceJson.schematics as WorkspaceTool)), - ).toPromise().then(done, done.fail); - }); - - it('gets workspace targets', (done) => { - const workspace = new Workspace(root, host); - workspace.loadWorkspaceFromJson(workspaceJson).pipe( - tap((ws) => expect(ws.getTargets()).toEqual(workspaceJson.targets as WorkspaceTool)), - ).toPromise().then(done, done.fail); - }); - - it('gets workspace architect when targets is not there', (done) => { - const workspace = new Workspace(root, host); - const workspaceJsonClone = { ...workspaceJson }; - workspaceJsonClone['architect'] = workspaceJsonClone['targets']; - delete workspaceJsonClone['targets']; - workspace.loadWorkspaceFromJson(workspaceJsonClone).pipe( - tap((ws) => expect(ws.getTargets()).toEqual(workspaceJson.targets as WorkspaceTool)), - ).toPromise().then(done, done.fail); - }); - - it('gets project cli', (done) => { - const workspace = new Workspace(root, host); - workspace.loadWorkspaceFromJson(workspaceJson).pipe( - tap((ws) => expect(ws.getProjectCli('app')) - .toEqual(workspaceJson.projects.app.cli as WorkspaceTool)), - ).toPromise().then(done, done.fail); - }); - - it('gets project schematics', (done) => { - const workspace = new Workspace(root, host); - workspace.loadWorkspaceFromJson(workspaceJson).pipe( - tap((ws) => expect(ws.getProjectSchematics('app')) - .toEqual(workspaceJson.projects.app.schematics as WorkspaceTool)), - ).toPromise().then(done, done.fail); - }); - - it('gets project targets', (done) => { - const workspace = new Workspace(root, host); - workspace.loadWorkspaceFromJson(workspaceJson).pipe( - tap((ws) => expect(ws.getProjectTargets('app')) - .toEqual(workspaceJson.projects.app.targets as WorkspaceTool)), - ).toPromise().then(done, done.fail); - }); - - it('gets project architect when targets is not there', (done) => { - const workspace = new Workspace(root, host); - const appJsonClone = { ...workspaceJson.projects.app }; - appJsonClone['architect'] = appJsonClone['targets']; - delete appJsonClone['targets']; - const simpleWorkspace = { - version: 1, - projects: { app: appJsonClone }, - }; - workspace.loadWorkspaceFromJson(simpleWorkspace).pipe( - tap((ws) => expect(ws.getProjectTargets('app')) - .toEqual(workspaceJson.projects.app.targets as WorkspaceTool)), - ).toPromise().then(done, done.fail); - }); - -}); diff --git a/packages/angular_devkit/schematics/BUILD b/packages/angular_devkit/schematics/BUILD deleted file mode 100644 index 613a672ff023..000000000000 --- a/packages/angular_devkit/schematics/BUILD +++ /dev/null @@ -1,222 +0,0 @@ -# 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 -package(default_visibility = ["//visibility:public"]) - -load("@build_bazel_rules_typescript//:defs.bzl", "ts_library") -load("@build_bazel_rules_nodejs//:defs.bzl", "jasmine_node_test") - -licenses(["notice"]) # MIT License - -# @angular-devkit/schematics - -ts_library( - name = "schematics", - srcs = glob( - include = ["src/**/*.ts"], - exclude = [ - "src/**/*_spec.ts", - "src/**/*_spec_large.ts", - "src/**/*_benchmark.ts", - ], - ), - module_name = "@angular-devkit/schematics", - module_root = "src/index.d.ts", - deps = [ - "//packages/angular_devkit/core", - "//packages/angular_devkit/core:node", # TODO: get rid of this for 6.0 - "@rxjs", - "@rxjs//operators", - # @typings: node - ], -) - -ts_library( - name = "schematics_test_lib", - srcs = glob( - include = [ - "src/**/*_spec.ts", - "src/**/*_spec_large.ts", - ], - ), - deps = [ - ":schematics", - ":testing", - "//packages/angular_devkit/core", - "//packages/angular_devkit/core:node", - "@rxjs", - "@rxjs//operators", - # @typings: jasmine - # @typings: node - ], -) - -jasmine_node_test( - name = "schematics_test", - srcs = [":schematics_test_lib"], -) - - -# @angular-devkit/schematics/tasks - -ts_library( - name = "tasks", - srcs = glob( - include = ["tasks/**/*.ts"], - exclude = [ - "tasks/node/**/*.ts", - "tasks/**/*_spec.ts", - "tasks/**/*_benchmark.ts", - "tasks/tslint-fix/test/**/*", - ], - ), - module_name = "@angular-devkit/schematics/tasks", - module_root = "tasks/index.d.ts", - deps = [ - ":schematics", - "//packages/angular_devkit/core", - "//packages/angular_devkit/core:node", - "@rxjs", - "@rxjs//operators", - # @typings: node - # @typings: tslint - # @typings: typescript - ], -) - -# @angular-devkit/schematics/tasks/node - -ts_library( - name = "tasks_node", - srcs = glob( - include = ["tasks/node/**/*.ts"], - exclude = [ - "tasks/node/**/*_spec.ts", - "tasks/node/**/*_benchmark.ts", - ], - ), - module_name = "@angular-devkit/schematics/tasks/node", - module_root = "tasks/node/index.d.ts", - deps = [ - ":schematics", - ":tasks", - "//packages/angular_devkit/core", - "//packages/angular_devkit/core:node", - "@rxjs", - "@rxjs//operators", - # @typings: node - # @typings: tslint - # @typings: typescript - ], -) - -ts_library( - name = "tasks_test_lib", - srcs = glob( - include = [ - "tasks/**/*_spec.ts", - "tasks/**/*_spec_large.ts", - "tasks/tslint-fix/test/**/*.ts", - ], - ), - data = [ - "tasks/tslint-fix/test/collection.json", - "tasks/tslint-fix/test/rules/customRuleRule.js", - ], - deps = [ - ":tasks", - ":testing", - ":schematics", - "//packages/angular_devkit/core", - "//packages/angular_devkit/core:node", - "//packages/angular_devkit/core:node_testing", - "@rxjs", - "@rxjs//operators", - # @typings: jasmine - # @typings: node - ], -) - - -jasmine_node_test( - name = "tasks_test", - srcs = [":tasks_test_lib"], -) - - -# @angular-devkit/schematics/tools - -ts_library( - name = "tools", - srcs = glob( - include = ["tools/**/*.ts"], - exclude = [ - "tools/**/*_spec.ts", - "tools/**/*_benchmark.ts", - "tools/test/**/*.ts", - ], - ), - module_name = "@angular-devkit/schematics/tools", - module_root = "tools/index.d.ts", - deps = [ - ":schematics", - ":tasks", - ":tasks_node", - "//packages/angular_devkit/core", - "//packages/angular_devkit/core:node", - "@rxjs", - "@rxjs//operators", - # @typings: node - ], -) - -ts_library( - name = "tools_test_lib", - srcs = glob( - include = [ - "tools/**/*_spec.ts", - "tools/**/*_spec_large.ts", - "tools/test/**/*.ts", - ], - ), - deps = [ - ":tools", - ":tasks", - ":schematics", - ":testing", - "//packages/angular_devkit/core", - "//packages/angular_devkit/core:node", - "//tests/angular_devkit/schematics/tools/file-system-engine-host:file_system_engine_host_test_lib", - "@rxjs", - "@rxjs//operators", - # @typings: jasmine - # @typings: node - ], -) - -jasmine_node_test( - name = "tools_test", - srcs = [":tools_test_lib"], -) - - -# @angular-devkit/schematics/testing - -ts_library( - name = "testing", - srcs = glob( - include = ["testing/**/*.ts"], - ), - module_name = "@angular-devkit/schematics/testing", - module_root = "testing/index.d.ts", - deps = [ - ":schematics", - ":tasks", - ":tasks_node", - ":tools", - "//packages/angular_devkit/core", - "@rxjs", - "@rxjs//operators", - ], -) \ No newline at end of file diff --git a/packages/angular_devkit/schematics/BUILD.bazel b/packages/angular_devkit/schematics/BUILD.bazel new file mode 100644 index 000000000000..29b14cd973bb --- /dev/null +++ b/packages/angular_devkit/schematics/BUILD.bazel @@ -0,0 +1,90 @@ +load("@devinfra//bazel/api-golden:index.bzl", "api_golden_test_npm_package") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("//tools:defaults.bzl", "jasmine_test", "npm_package", "ts_project") + +# Copyright Google LLC. All Rights Reserved. +# +# Use of this source code is governed by an MIT-style license that can be +# found in the LICENSE file at https://angular.dev/license +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +npm_link_all_packages() + +# @angular-devkit/schematics + +ts_project( + name = "schematics", + srcs = glob( + include = ["src/**/*.ts"], + exclude = [ + "src/**/*_spec.ts", + ], + ) + ["index.ts"], + data = [ + "package.json", + ], + deps = [ + ":node_modules/@angular-devkit/core", + ":node_modules/jsonc-parser", + ":node_modules/magic-string", + ":node_modules/rxjs", + "//:node_modules/@types/node", + ], +) + +ts_project( + name = "schematics_test_lib", + testonly = True, + srcs = glob(["src/**/*_spec.ts"]), + deps = [ + ":node_modules/@angular-devkit/core", + ":node_modules/rxjs", + ":schematics", + "//:node_modules/@types/jasmine", + "//packages/angular_devkit/schematics/testing", + ], +) + +jasmine_test( + name = "test", + data = [":schematics_test_lib"], +) + +genrule( + name = "license", + srcs = ["//:LICENSE"], + outs = ["LICENSE"], + cmd = "cp $(execpath //:LICENSE) $@", +) + +npm_package( + name = "pkg", + pkg_deps = [ + "//packages/angular_devkit/core:package.json", + ], + tags = ["release-package"], + deps = [ + ":README.md", + ":collection-schema.json", + ":license", + ":schematics", + "//packages/angular_devkit/schematics/tasks", + "//packages/angular_devkit/schematics/testing", + "//packages/angular_devkit/schematics/tools", + ], +) + +api_golden_test_npm_package( + name = "schematics_api", + data = [ + ":npm_package", + "//goldens:public-api", + ], + golden_dir = "goldens/public-api/angular_devkit/schematics", + npm_package = "packages/angular_devkit/schematics/npm_package", + types = { + "//:node_modules/@types/node": "node", + }, +) diff --git a/packages/angular_devkit/schematics/README.md b/packages/angular_devkit/schematics/README.md index 9b9cb0ac35f7..a0b9d8e3e2a4 100644 --- a/packages/angular_devkit/schematics/README.md +++ b/packages/angular_devkit/schematics/README.md @@ -1,33 +1,37 @@ # Schematics + > A scaffolding library for the modern web. ## Description + Schematics are generators that transform an existing filesystem. They can create files, refactor existing files, or move files around. What distinguishes Schematics from other generators, such as Yeoman or Yarn Create, is that schematics are purely descriptive; no changes are applied to the actual filesystem until everything is ready to be committed. There is no side effect, by design, in Schematics. # Glossary -| Term | Description | -|------|-------------| -| **Schematics** | A generator that executes descriptive code without side effects on an existing file system. | -| **Collection** | A list of schematics metadata. Schematics can be referred by name inside a collection. | -| **Tool** | The code using the Schematics library. | -| **Tree** | A staging area for changes, containing the original file system, and a list of changes to apply to it. | -| **Rule** | A function that applies actions to a `Tree`. It returns a new `Tree` that will contain all transformations to be applied. | -| **Source** | A function that creates an entirely new `Tree` from an empty filesystem. For example, a file source could read files from disk and create a Create Action for each of those. -| **Action** | An atomic operation to be validated and committed to a filesystem or a `Tree`. Actions are created by schematics. | -| **Sink** | The final destination of all `Action`s. | +| Term | Description | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Schematics** | A generator that executes descriptive code without side effects on an existing file system. | +| **Collection** | A list of schematics metadata. Schematics can be referred by name inside a collection. | +| **Tool** | The code using the Schematics library. | +| **Tree** | A staging area for changes, containing the original file system, and a list of changes to apply to it. | +| **Rule** | A function that applies actions to a `Tree`. It returns a new `Tree` that will contain all transformations to be applied. | +| **Source** | A function that creates an entirely new `Tree` from an empty filesystem. For example, a file source could read files from disk and create a Create Action for each of those. | +| **Action** | An atomic operation to be validated and committed to a filesystem or a `Tree`. Actions are created by schematics. | +| **Sink** | The final destination of all `Action`s. | +| **Task** | A Task is a way to execute an external command or script in a schematic. A Task can be used to perform actions such as installing dependencies, running tests, or building a project. A Task is created by using the `SchematicContext` object and can be scheduled to run before or after the schematic `Tree` is applied. | # Tooling -Schematics is a library, and does not work by itself. A [reference CLI](https://github.com/angular/angular-cli/blob/master/packages/angular_devkit/schematics_cli/bin/schematics.ts) is available on this repository, but is not published on NPM. This document explains the library usage and the tooling API, but does not go into the tool implementation itself. + +Schematics is a library, and does not work by itself. A [reference CLI](https://github.com/angular/angular-cli/blob/main/packages/angular_devkit/schematics_cli/bin/schematics.ts) is available on this repository, and is published on NPM at [@angular-devkit/schematics-cli](https://www.npmjs.com/package/@angular-devkit/schematics-cli). This document explains the library usage and the tooling API, but does not go into the tool implementation itself. The tooling is responsible for the following tasks: 1. Create the Schematic Engine, and pass in a Collection and Schematic loader. 1. Understand and respect the Schematics metadata and dependencies between collections. Schematics can refer to dependencies, and it's the responsibility of the tool to honor those dependencies. The reference CLI uses NPM packages for its collections. 1. Create the Options object. Options can be anything, but the schematics can specify a JSON Schema that should be respected. The reference CLI, for example, parses the arguments as a JSON object and validates it with the Schema specified by the collection. - 1. Schematics provides some JSON Schema formats for validation that tooling should add. These validate paths, html selectors and app names. Please check the reference CLI for how these can be added. +1. Schematics provides some JSON Schema formats for validation that tooling should add. These validate paths, html selectors and app names. Please check the reference CLI for how these can be added. 1. Call the schematics with the original Tree. The tree should represent the initial state of the filesystem. The reference CLI uses the current directory for this. 1. Create a Sink and commit the result of the schematics to the Sink. Many sinks are provided by the library; FileSystemSink and DryRunSink are examples. 1. Output any logs propagated by the library, including debugging information. @@ -35,25 +39,30 @@ The tooling is responsible for the following tasks: The tooling API is composed of the following pieces: ## Engine + The `SchematicEngine` is responsible for loading and constructing `Collection`s and `Schematics`. When creating an engine, the tooling provides an `EngineHost` interface that understands how to create a `CollectionDescription` from a name, and how to create a `SchematicDescription`. # Schematics (Generators) + Schematics are generators and part of a `Collection`. ## Collection + A Collection is defined by a `collection.json` file (in the reference CLI). This JSON defines the following properties: -| Prop Name | Type | Description | -|---|---|---| -| **name** | `string` | The name of the collection. | -| **version** | `string` | Unused field. | +| Prop Name | Type | Description | +| ----------- | -------- | --------------------------- | +| **name** | `string` | The name of the collection. | +| **version** | `string` | Unused field. | ## Schematic # Operators, Sources and Rules + A `Source` is a generator of a `Tree`; it creates an entirely new root tree from nothing. A `Rule` is a transformation from one `Tree` to another. A `Schematic` (at the root) is a `Rule` that is normally applied on the filesystem. ## Operators + `FileOperator`s apply changes to a single `FileEntry` and return a new `FileEntry`. The result follows these rules: 1. If the `FileEntry` returned is null, a `DeleteAction` will be added to the action list. @@ -63,66 +72,71 @@ A `Source` is a generator of a `Tree`; it creates an entirely new root tree from It is impossible to create files using a `FileOperator`. ## Provided Operators + The Schematics library provides multiple `Operator` factories by default that cover basic use cases: -| FileOperator | Description | -|---|---| -| `contentTemplate(options: T)` | Apply a content template (see the Template section) | -| `pathTemplate(options: T)` | Apply a path template (see the Template section) | +| FileOperator | Description | +| -------------------------------- | -------------------------------------------------------------------- | +| `contentTemplate(options: T)` | Apply a content template (see the [Templating](#templating) section) | +| `pathTemplate(options: T)` | Apply a path template (see the [Templating](#templating) section) | ## Provided Sources + The Schematics library additionally provides multiple `Source` factories by default: -| Source | Description | -|---|---| -| `empty()` | Creates a source that returns an empty `Tree`. | -| `source(tree: Tree)` | Creates a `Source` that returns the `Tree` passed in as argument. | -| `url(url: string)` | Loads a list of files from the given URL and returns a `Tree` with the files as `CreateAction` applied to an empty `Tree`. | -| `apply(source: Source, rules: Rule[])` | Apply a list of `Rule`s to a source, and return the resulting `Source`. | +| Source | Description | +| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `empty()` | Creates a source that returns an empty `Tree`. | +| `source(tree: Tree)` | Creates a `Source` that returns the `Tree` passed in as argument. | +| `url(url: string)` | Loads a list of files from the given URL and returns a `Tree` with the files as `CreateAction` applied to an empty `Tree`. | +| `apply(source: Source, rules: Rule[])` | Apply a list of `Rule`s to a source, and return the resulting `Source`. | ## Provided Rules -The schematics library also provides `Rule` factories by default: -| Rule | Description | -|---|---| -| `noop()` | Returns the input `Tree` as is. | -| `chain(rules: Rule[])` | Returns a `Rule` that's the concatenation of other `Rule`s. | -| `forEach(op: FileOperator)` | Returns a `Rule` that applies an operator to every file of the input `Tree`. | -| `move(root: string)` | Moves all the files from the input to a subdirectory. | -| `merge(other: Tree)` | Merge the input `Tree` with the other `Tree`. | -| `contentTemplate(options: T)` | Apply a content template (see the Template section) to the entire `Tree`. | -| `pathTemplate(options: T)` | Apply a path template (see the Template section) to the entire `Tree`. | -| `template(options: T)` | Apply both path and content templates (see the Template section) to the entire `Tree`. | -| `filter(predicate: FilePredicate)` | Returns the input `Tree` with files that do not pass the `FilePredicate`. | +The schematics library also provides `Rule` factories by default: +| Rule | Description | +| ------------------------------------------- | -------------------------------------------------------------------------------------- | +| `noop()` | Returns the input `Tree` as is. | +| `chain(rules: Rule[])` | Returns a `Rule` that's the concatenation of other `Rule`s. | +| `forEach(op: FileOperator)` | Returns a `Rule` that applies an operator to every file of the input `Tree`. | +| `move(root: string)` | Moves all the files from the input to a subdirectory. | +| `merge(other: Tree)` | Merge the input `Tree` with the other `Tree`. | +| `contentTemplate(options: T)` | Apply a content template (see the Template section) to the entire `Tree`. | +| `pathTemplate(options: T)` | Apply a path template (see the Template section) to the entire `Tree`. | +| `template(options: T)` | Apply both path and content templates (see the Template section) to the entire `Tree`. | +| `filter(predicate: FilePredicate)` | Returns the input `Tree` with files that do not pass the `FilePredicate`. | # Templating + As referenced above, some functions are based upon a file templating system, which consists of path and content templating. The system operates on placeholders defined inside files or their paths as loaded in the `Tree` and fills these in as defined in the following, using values passed into the `Rule` which applies the templating (i.e. `template(options: T)`). ## Path Templating -| Placeholder | Description | -|---|---| -| __variable__ | Replaced with the value of `variable`. | -| __variable@function__ | Replaced with the result of the call `function(variable)`. Can be chained to the left (`__variable@function1@function2__ ` etc). | + +| Placeholder | Description | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `__variable__` | Replaced with the value of `variable`. | +| `__variable@function__` | Replaced with the result of the call `function(variable)`. Can be chained to the left (`__variable@function1@function2__ ` etc). | ## Content Templating -| Placeholder | Description | -|---|---| -| <%= expression %> | Replaced with the result of the call of the given expression. This only supports direct expressions, no structural (for/if/...) JavaScript. | -| <%- expression %> | Same as above, but the value of the result will be escaped for HTML when inserted (i.e. replacing '<' with '<') | -| <% inline code %> | Inserts the given code into the template structure, allowing to insert structural JavaScript. | -| <%# text %> | A comment, which gets entirely dropped. | +| Placeholder | Description | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `<%= expression %>` | Replaced with the result of the call of the given expression. This only supports direct expressions, no structural (for/if/...) JavaScript. | +| `<%- expression %>` | Same as above, but the value of the result will be escaped for HTML when inserted (i.e. replacing '<' with '\<') | +| `<% inline code %>` | Inserts the given code into the template structure, allowing to insert structural JavaScript. | +| `<%# text %>` | A comment, which gets entirely dropped. | # Examples ## Simple + An example of a simple Schematics which creates a "hello world" file, using an option to determine its path: ```typescript -import {Tree} from '@angular-devkit/schematics'; +import { Tree } from '@angular-devkit/schematics'; export default function MySchematic(options: any) { return (tree: Tree) => { @@ -138,6 +152,7 @@ A few things from this example: 1. It returns a [`Rule`](src/engine/interface.ts#L73), which is a transformation from a `Tree` to another `Tree`. ## Templating + A simplified example of a Schematics which creates a file containing a new Class, using an option to determine its name: ```typescript @@ -152,8 +167,15 @@ export class <%= classify(name) %> { import { strings } from '@angular-devkit/core'; import { - Rule, SchematicContext, SchematicsException, Tree, - apply, branchAndMerge, mergeWith, template, url, + Rule, + SchematicContext, + SchematicsException, + Tree, + apply, + branchAndMerge, + mergeWith, + template, + url, } from '@angular-devkit/schematics'; import { Schema as ClassOptions } from './schema'; @@ -163,15 +185,12 @@ export default function (options: ClassOptions): Rule { throw new SchematicsException('Option (name) is required.'); } - const templateSource = apply( - url('./files'), - [ - template({ - ...strings, - ...options, - }), - ] - ); + const templateSource = apply(url('./files'), [ + template({ + ...strings, + ...options, + }), + ]); return branchAndMerge(mergeWith(templateSource)); }; @@ -179,14 +198,8 @@ export default function (options: ClassOptions): Rule { ``` Additional things from this example: + 1. `strings` provides the used `dasherize` and `classify` functions, among others. 1. The files are on-disk in the same root directory as the `index.ts` and loaded into a `Tree`. 1. Then the `template` `Rule` fills in the specified templating placeholders. For this, it only knows about the variables and functions passed to it via the options-object. 1. Finally, the resulting `Tree`, containing the new file, is merged with the existing files of the project which the Schematic is run on. - -# Future Work -Schematics is not done yet. Here's a list of things we are considering: - -* Smart defaults for Options. Having a JavaScript function for default values based on other default values. -* Prompt for input options. This should only be prompted for the original schematics, dependencies to other schematics should not trigger another prompting. -* Tasks for running tooling-specific jobs before and after a schematics has been scaffolded. Such tasks can involve initialize git, or npm install. A specific list of tasks should be provided by the tool, with unsupported tasks generating an error. diff --git a/packages/angular_devkit/schematics/collection-schema.json b/packages/angular_devkit/schematics/collection-schema.json index 83ad73562af6..6489280417e2 100644 --- a/packages/angular_devkit/schematics/collection-schema.json +++ b/packages/angular_devkit/schematics/collection-schema.json @@ -1,6 +1,6 @@ { - "$schema": "http://json-schema.org/schema", - "id": "SchematicsCollectionSchema", + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "SchematicsCollectionSchema", "title": "Collection Schema for validating a 'collection.json'.", "type": "object", "properties": { @@ -26,6 +26,12 @@ "additionalProperties": { "type": "object", "properties": { + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, "factory": { "type": "string", "description": "A folder or file path to the schematic factory" @@ -53,17 +59,12 @@ "description": "Whether or not this schematic can be called from an external schematic, or a tool. This implies hidden: true." } }, - "required": [ - "factory", - "description" - ] + "required": ["factory", "description"] } }, "version": { "type": "string" } }, - "required": [ - "schematics" - ] + "required": ["schematics"] } diff --git a/packages/angular_devkit/schematics/index.ts b/packages/angular_devkit/schematics/index.ts new file mode 100644 index 000000000000..e6da94cc7ded --- /dev/null +++ b/packages/angular_devkit/schematics/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './src/index'; diff --git a/packages/angular_devkit/schematics/package.json b/packages/angular_devkit/schematics/package.json index be96be5dc296..5ccb8d0ff0e4 100644 --- a/packages/angular_devkit/schematics/package.json +++ b/packages/angular_devkit/schematics/package.json @@ -1,9 +1,29 @@ { "name": "@angular-devkit/schematics", - "version": "0.0.0", + "version": "0.0.0-PLACEHOLDER", "description": "Angular Schematics - Library", "main": "src/index.js", "typings": "src/index.d.ts", + "exports": { + ".": { + "types": "./src/index.d.ts", + "default": "./src/index.js" + }, + "./tasks": { + "types": "./tasks/index.d.ts", + "default": "./tasks/index.js" + }, + "./testing": { + "types": "./testing/index.d.ts", + "default": "./testing/index.js" + }, + "./tools": { + "types": "./tools/index.d.ts", + "default": "./tools/index.js" + }, + "./collection-schema.json": "./collection-schema.json", + "./package.json": "./package.json" + }, "keywords": [ "blueprints", "scaffolding", @@ -13,7 +33,10 @@ "schematics" ], "dependencies": { - "@angular-devkit/core": "0.0.0", - "rxjs": "6.3.3" + "@angular-devkit/core": "workspace:0.0.0-PLACEHOLDER", + "jsonc-parser": "3.3.1", + "magic-string": "1.4.5", + "ora": "10.0.0", + "rxjs": "7.8.2" } } diff --git a/packages/angular_devkit/schematics/src/engine/engine.ts b/packages/angular_devkit/schematics/src/engine/engine.ts index 0db56c78c11a..c58ccac7877f 100644 --- a/packages/angular_devkit/schematics/src/engine/engine.ts +++ b/packages/angular_devkit/schematics/src/engine/engine.ts @@ -1,14 +1,14 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { BaseException, PriorityQueue, logging } from '@angular-devkit/core'; -import { Observable, from as observableFrom } from 'rxjs'; -import { concatMap } from 'rxjs/operators'; -import { Url } from 'url'; +import { Url } from 'node:url'; +import { Observable, concatMap, from as observableFrom } from 'rxjs'; import { MergeStrategy } from '../tree/interface'; import { NullTree } from '../tree/null'; import { empty } from '../tree/static'; @@ -32,13 +32,16 @@ import { } from './interface'; import { SchematicImpl } from './schematic'; - export class UnknownUrlSourceProtocol extends BaseException { - constructor(url: string) { super(`Unknown Protocol on url "${url}".`); } + constructor(url: string) { + super(`Unknown Protocol on url "${url}".`); + } } export class UnknownCollectionException extends BaseException { - constructor(name: string) { super(`Unknown collection "${name}".`); } + constructor(name: string) { + super(`Unknown collection "${name}".`); + } } export class CircularCollectionException extends BaseException { @@ -60,7 +63,9 @@ export class PrivateSchematicException extends BaseException { } export class SchematicEngineConflictingException extends BaseException { - constructor() { super(`A schematic was called from a different engine as its parent.`); } + constructor() { + super(`A schematic was called from a different engine as its parent.`); + } } export class UnregisteredTaskException extends BaseException { @@ -77,21 +82,27 @@ export class UnknownTaskDependencyException extends BaseException { } export class CollectionImpl - implements Collection { - constructor(private _description: CollectionDescription, - private _engine: SchematicEngine, - public readonly baseDescriptions?: Array>) { + implements Collection +{ + constructor( + private _description: CollectionDescription, + private _engine: SchematicEngine, + public readonly baseDescriptions?: CollectionDescription[] | undefined, + ) {} + + get description(): CollectionDescription { + return this._description; + } + get name(): string { + return this.description.name || ''; } - - get description() { return this._description; } - get name() { return this.description.name || ''; } createSchematic(name: string, allowPrivate = false): Schematic { return this._engine.createSchematic(name, this, allowPrivate); } - listSchematicNames(): string[] { - return this._engine.listSchematicNames(this); + listSchematicNames(includeHidden?: boolean): string[] { + return this._engine.listSchematicNames(this, includeHidden); } } @@ -117,7 +128,7 @@ export class TaskScheduler { return new Set(); } - const tasks = dependencies.map(dep => { + const tasks = dependencies.map((dep) => { const task = this._taskIds.get(dep); if (!task) { throw new UnknownTaskDependencyException(dep); @@ -129,7 +140,7 @@ export class TaskScheduler { return new Set(tasks); } - schedule(taskConfiguration: TaskConfiguration): TaskId { + schedule(taskConfiguration: TaskConfiguration): TaskId { const dependencies = this._mapDependencies(taskConfiguration.dependencies); const priority = this._calculatePriority(dependencies); @@ -155,44 +166,54 @@ export class TaskScheduler { return tasks; } - } - export class SchematicEngine - implements Engine { - + implements Engine +{ private _collectionCache = new Map>(); - private _schematicCache - = new Map>>(); + private _schematicCache = new WeakMap< + Collection, + Map> + >(); private _taskSchedulers = new Array(); - constructor(private _host: EngineHost, protected _workflow?: Workflow) { - } + constructor( + private _host: EngineHost, + protected _workflow?: Workflow | undefined, + ) {} - get workflow() { return this._workflow || null; } - get defaultMergeStrategy() { return this._host.defaultMergeStrategy || MergeStrategy.Default; } + get workflow(): Workflow | null { + return this._workflow || null; + } + get defaultMergeStrategy(): MergeStrategy { + return this._host.defaultMergeStrategy || MergeStrategy.Default; + } - createCollection(name: string): Collection { + createCollection( + name: string, + requester?: Collection, + ): Collection { let collection = this._collectionCache.get(name); if (collection) { return collection; } - const [description, bases] = this._createCollectionDescription(name); + const [description, bases] = this._createCollectionDescription(name, requester?.description); collection = new CollectionImpl(description, this, bases); this._collectionCache.set(name, collection); - this._schematicCache.set(name, new Map()); + this._schematicCache.set(collection, new Map()); return collection; } private _createCollectionDescription( name: string, + requester?: CollectionDescription, parentNames?: Set, ): [CollectionDescription, Array>] { - const description = this._host.createCollectionDescription(name); + const description = this._host.createCollectionDescription(name, requester); if (!description) { throw new UnknownCollectionException(name); } @@ -204,7 +225,11 @@ export class SchematicEngine()).add(description.name); for (const baseName of description.extends) { - const [base, baseBases] = this._createCollectionDescription(baseName, new Set(parentNames)); + const [base, baseBases] = this._createCollectionDescription( + baseName, + description, + new Set(parentNames), + ); bases.unshift(base, ...baseBases); } @@ -231,13 +256,14 @@ export class SchematicEngine = { - debug: parent && parent.debug || false, + debug: (parent && parent.debug) || false, engine: this, - logger: (parent && parent.logger && parent.logger.createChild(schematic.description.name)) - || new logging.NullLogger(), + logger: + (parent && parent.logger && parent.logger.createChild(schematic.description.name)) || + new logging.NullLogger(), schematic, - strategy: (parent && parent.strategy !== undefined) - ? parent.strategy : this.defaultMergeStrategy, + strategy: + parent && parent.strategy !== undefined ? parent.strategy : this.defaultMergeStrategy, interactive, addTask, }; @@ -251,7 +277,7 @@ export class SchematicEngine( + function addTask( task: TaskConfigurationGenerator, dependencies?: Array, ): TaskId { @@ -277,14 +303,9 @@ export class SchematicEngine, allowPrivate = false, ): Schematic { - const collectionImpl = this._collectionCache.get(collection.description.name); - const schematicMap = this._schematicCache.get(collection.description.name); - if (!collectionImpl || !schematicMap || collectionImpl !== collection) { - // This is weird, maybe the collection was created by another engine? - throw new UnknownCollectionException(collection.description.name); - } + const schematicMap = this._schematicCache.get(collection); - let schematic = schematicMap.get(name); + let schematic = schematicMap?.get(name); if (schematic) { return schematic; } @@ -314,17 +335,20 @@ export class SchematicEngine(description, factory, collection, this); - schematicMap.set(name, schematic); + schematicMap?.set(name, schematic); return schematic; } - listSchematicNames(collection: Collection): string[] { - const names = this._host.listSchematicNames(collection.description); + listSchematicNames( + collection: Collection, + includeHidden?: boolean, + ): string[] { + const names = this._host.listSchematicNames(collection.description, includeHidden); if (collection.baseDescriptions) { for (const base of collection.baseDescriptions) { - names.push(...this._host.listSchematicNames(base)); + names.push(...this._host.listSchematicNames(base, includeHidden)); } } @@ -342,40 +366,42 @@ export class SchematicEngine): Source { switch (url.protocol) { - case 'null:': return () => new NullTree(); - case 'empty:': return () => empty(); - default: - const hostSource = this._host.createSourceFromUrl(url, context); - if (!hostSource) { - throw new UnknownUrlSourceProtocol(url.toString()); - } + case 'null:': + return () => new NullTree(); + case 'empty:': + return () => empty(); + } - return hostSource; + const hostSource = this._host.createSourceFromUrl(url, context); + if (!hostSource) { + throw new UnknownUrlSourceProtocol(url.toString()); } + + return hostSource; } executePostTasks(): Observable { const executors = new Map(); - const taskObservable = observableFrom(this._taskSchedulers) - .pipe( - concatMap(scheduler => scheduler.finalize()), - concatMap(task => { - const { name, options } = task.configuration; + const taskObservable = observableFrom(this._taskSchedulers).pipe( + concatMap((scheduler) => scheduler.finalize()), + concatMap((task) => { + const { name, options } = task.configuration; - const executor = executors.get(name); - if (executor) { - return executor(options, task.context); - } + const executor = executors.get(name); + if (executor) { + return executor(options, task.context); + } - return this._host.createTaskExecutor(name) - .pipe(concatMap(executor => { - executors.set(name, executor); + return this._host.createTaskExecutor(name).pipe( + concatMap((executor) => { + executors.set(name, executor); - return executor(options, task.context); - })); - }), - ); + return executor(options, task.context); + }), + ); + }), + ); return taskObservable; } diff --git a/packages/angular_devkit/schematics/src/engine/index.ts b/packages/angular_devkit/schematics/src/engine/index.ts index 9f8b476cc297..3223bbfed070 100644 --- a/packages/angular_devkit/schematics/src/engine/index.ts +++ b/packages/angular_devkit/schematics/src/engine/index.ts @@ -1,10 +1,11 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ + export * from './engine'; export * from './interface'; export * from './schematic'; diff --git a/packages/angular_devkit/schematics/src/engine/interface.ts b/packages/angular_devkit/schematics/src/engine/interface.ts index 0e43788c2340..ddc574fe25b1 100644 --- a/packages/angular_devkit/schematics/src/engine/interface.ts +++ b/packages/angular_devkit/schematics/src/engine/interface.ts @@ -1,17 +1,17 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { Url } from 'node:url'; import { Observable } from 'rxjs'; -import { Url } from 'url'; import { FileEntry, MergeStrategy, Tree } from '../tree/interface'; import { Workflow } from '../workflow/interface'; - export interface TaskConfiguration { name: string; dependencies?: Array; @@ -22,8 +22,10 @@ export interface TaskConfigurationGenerator { toConfiguration(): TaskConfiguration; } -export type TaskExecutor - = (options: T | undefined, context: SchematicContext) => Promise | Observable; +export type TaskExecutor = ( + options: T | undefined, + context: SchematicContext, +) => Promise | Observable; export interface TaskExecutorFactory { readonly name: string; @@ -36,12 +38,13 @@ export interface TaskId { export interface TaskInfo { readonly id: number; - readonly priority: number; + readonly priority: number; readonly configuration: TaskConfiguration; readonly context: SchematicContext; } export interface ExecutionOptions { + scope: string; interactive: boolean; } @@ -60,35 +63,39 @@ export type CollectionDescription = Collecti * needs to run. The SchematicMetadataT and CollectionMetadataT type parameters contain additional * metadata that you want to store while remaining type-safe. */ -export type SchematicDescription = SchematicMetadataT & { +export type SchematicDescription< + CollectionMetadataT extends object, + SchematicMetadataT extends object, +> = SchematicMetadataT & { readonly collection: CollectionDescription; readonly name: string; readonly private?: boolean; readonly hidden?: boolean; }; - /** * The Host for the Engine. Specifically, the piece of the tooling responsible for resolving * collections and schematics descriptions. The SchematicMetadataT and CollectionMetadataT type * parameters contain additional metadata that you want to store while remaining type-safe. */ export interface EngineHost { - createCollectionDescription(name: string): CollectionDescription; - /** - * @deprecated Use `listSchematicNames`. - */ - listSchematics(collection: Collection): string[]; - listSchematicNames(collection: CollectionDescription): string[]; + createCollectionDescription( + name: string, + requester?: CollectionDescription, + ): CollectionDescription; + listSchematicNames( + collection: CollectionDescription, + includeHidden?: boolean, + ): string[]; createSchematicDescription( - name: string, - collection: CollectionDescription): - SchematicDescription | null; + name: string, + collection: CollectionDescription, + ): SchematicDescription | null; getSchematicRuleFactory( - schematic: SchematicDescription, - collection: CollectionDescription): RuleFactory; + schematic: SchematicDescription, + collection: CollectionDescription, + ): RuleFactory; createSourceFromUrl( url: Url, context: TypedSchematicContext, @@ -107,7 +114,6 @@ export interface EngineHost { - createCollection(name: string): Collection; + createCollection( + name: string, + requester?: Collection, + ): Collection; createContext( schematic: Schematic, parent?: Partial>, executionOptions?: Partial, ): TypedSchematicContext; createSchematic( - name: string, - collection: Collection, + name: string, + collection: Collection, ): Schematic; createSourceFromUrl( url: Url, context: TypedSchematicContext, ): Source; transformOptions( - schematic: Schematic, - options: OptionT, - context?: TypedSchematicContext, + schematic: Schematic, + options: OptionT, + context?: TypedSchematicContext, ): Observable; executePostTasks(): Observable; @@ -144,7 +153,6 @@ export interface Engine; - listSchematicNames(): string[]; + listSchematicNames(includeHidden?: boolean): string[]; } - /** * A Schematic as created by the Engine. This should be used by the tool to execute the main * schematics, or by rules to execute other schematics as well. @@ -177,37 +184,38 @@ export interface Schematic; } - /** * A SchematicContext. Contains information necessary for Schematics to execute some rules, for * example when using another schematics, as we need the engine and collection. */ -export interface TypedSchematicContext { +export interface TypedSchematicContext< + CollectionMetadataT extends object, + SchematicMetadataT extends object, +> { readonly debug: boolean; readonly engine: Engine; readonly logger: logging.LoggerApi; readonly schematic: Schematic; readonly strategy: MergeStrategy; readonly interactive: boolean; - addTask(task: TaskConfigurationGenerator, dependencies?: Array): TaskId; + addTask( + task: TaskConfigurationGenerator, + dependencies?: Array, + ): TaskId; } - /** * This is used by the Schematics implementations in order to avoid needing to have typing from * the tooling. Schematics are not specific to a tool. */ export type SchematicContext = TypedSchematicContext<{}, {}>; - /** * A rule factory, which is normally the way schematics are implemented. Returned by the tooling * after loading a schematic description. */ export type RuleFactory = (options: T) => Rule; - /** * A FileOperator applies changes synchronously to a FileEntry. An async operator returns * asynchronously. We separate them so that the type system can catch early errors. @@ -215,7 +223,6 @@ export type RuleFactory = (options: T) => Rule; export type FileOperator = (entry: FileEntry) => FileEntry | null; export type AsyncFileOperator = (tree: FileEntry) => Observable; - /** * A source is a function that generates a Tree from a specific context. A rule transforms a tree * into another tree from a specific context. In both cases, an Observable can be returned if @@ -226,4 +233,7 @@ export type AsyncFileOperator = (tree: FileEntry) => Observable Tree | Observable; -export type Rule = (tree: Tree, context: SchematicContext) => Tree | Observable | Rule | void; +export type Rule = ( + tree: Tree, + context: SchematicContext, +) => Tree | Observable | Rule | Promise | void; diff --git a/packages/angular_devkit/schematics/src/engine/schematic.ts b/packages/angular_devkit/schematics/src/engine/schematic.ts index fd3acbf774df..bf99bbf63493 100644 --- a/packages/angular_devkit/schematics/src/engine/schematic.ts +++ b/packages/angular_devkit/schematics/src/engine/schematic.ts @@ -1,15 +1,16 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { BaseException } from '@angular-devkit/core'; -import { Observable, of as observableOf } from 'rxjs'; -import { concatMap, first, map } from 'rxjs/operators'; +import { Observable, concatMap, first, map } from 'rxjs'; import { callRule } from '../rules/call'; import { Tree } from '../tree/interface'; +import { ScopedTree } from '../tree/scoped'; import { Collection, Engine, @@ -20,28 +21,32 @@ import { TypedSchematicContext, } from './interface'; - export class InvalidSchematicsNameException extends BaseException { constructor(name: string) { super(`Schematics has invalid name: "${name}".`); } } - export class SchematicImpl - implements Schematic { - - constructor(private _description: SchematicDescription, - private _factory: RuleFactory<{}>, - private _collection: Collection, - private _engine: Engine) { + implements Schematic +{ + constructor( + private _description: SchematicDescription, + private _factory: RuleFactory<{}>, + private _collection: Collection, + private _engine: Engine, + ) { if (!_description.name.match(/^[-@/_.a-zA-Z0-9]+$/)) { throw new InvalidSchematicsNameException(_description.name); } } - get description() { return this._description; } - get collection() { return this._collection; } + get description(): SchematicDescription { + return this._description; + } + get collection(): Collection { + return this._collection; + } call( options: OptionT, @@ -51,15 +56,37 @@ export class SchematicImpl { const context = this._engine.createContext(this, parentContext, executionOptions); - return host - .pipe( - first(), - concatMap(tree => this._engine.transformOptions(this, options, context).pipe( - map(o => [tree, o]), - )), - concatMap(([tree, transformedOptions]: [Tree, OptionT]) => { - return callRule(this._factory(transformedOptions), observableOf(tree), context); - }), - ); + return host.pipe( + first(), + concatMap((tree) => + this._engine + .transformOptions(this, options, context) + .pipe(map((o) => [tree, o] as [Tree, OptionT])), + ), + concatMap(([tree, transformedOptions]) => { + let input: Tree; + let scoped = false; + if (executionOptions && executionOptions.scope) { + scoped = true; + input = new ScopedTree(tree, executionOptions.scope); + } else { + input = tree; + } + + return callRule(this._factory(transformedOptions), input, context).pipe( + map((output) => { + if (output === input) { + return tree; + } else if (scoped) { + tree.merge(output); + + return tree; + } else { + return output; + } + }), + ); + }), + ); } } diff --git a/packages/angular_devkit/schematics/src/engine/schematic_spec.ts b/packages/angular_devkit/schematics/src/engine/schematic_spec.ts index 7a3fbcfa93f9..8beb6ad81e9f 100644 --- a/packages/angular_devkit/schematics/src/engine/schematic_spec.ts +++ b/packages/angular_devkit/schematics/src/engine/schematic_spec.ts @@ -1,20 +1,20 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -// tslint:disable:no-non-null-assertion + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { logging } from '@angular-devkit/core'; -import { of as observableOf } from 'rxjs'; +import { lastValueFrom, of as observableOf } from 'rxjs'; import { chain } from '../rules/base'; import { MergeStrategy, Tree } from '../tree/interface'; import { branch, empty } from '../tree/static'; import { CollectionDescription, Engine, Rule, Schematic, SchematicDescription } from './interface'; import { SchematicImpl } from './schematic'; - type CollectionT = { description: string; }; @@ -40,17 +40,15 @@ const collection = { description: 'description', } as CollectionDescription; - function files(tree: Tree) { const files: string[] = []; - tree.visit(x => files.push(x)); + tree.visit((x) => files.push(x)); return files; } - describe('Schematic', () => { - it('works with a rule', done => { + it('works with a rule', async () => { let inner: Tree | null = null; const desc: SchematicDescription = { collection, @@ -65,17 +63,13 @@ describe('Schematic', () => { }, }; - const schematic = new SchematicImpl(desc, desc.factory, null !, engine); - schematic.call({}, observableOf(empty())) - .toPromise() - .then(x => { - expect(files(inner !)).toEqual([]); - expect(files(x)).toEqual(['/a/b/c']); - }) - .then(done, done.fail); + const schematic = new SchematicImpl(desc, desc.factory, null!, engine); + const x = await lastValueFrom(schematic.call({}, observableOf(empty()))); + expect(files(inner!)).toEqual([]); + expect(files(x)).toEqual(['/a/b/c']); }); - it('works with a rule that returns an observable', done => { + it('works with a rule that returns an observable', async () => { let inner: Tree | null = null; const desc: SchematicDescription = { collection, @@ -89,31 +83,30 @@ describe('Schematic', () => { }, }; - - const schematic = new SchematicImpl(desc, desc.factory, null !, engine); - schematic.call({}, observableOf(empty())) - .toPromise() - .then(x => { - expect(files(inner !)).toEqual([]); - expect(files(x)).toEqual([]); - expect(inner).not.toBe(x); - }) - .then(done, done.fail); + const schematic = new SchematicImpl(desc, desc.factory, null!, engine); + const x = await lastValueFrom(schematic.call({}, observableOf(empty()))); + expect(files(inner!)).toEqual([]); + expect(files(x)).toEqual([]); + expect(inner!).not.toBe(x); }); - it('works with nested chained function rules', done => { + it('works with nested chained function rules', async () => { let chainCount = 0; let oneCount = 0; let twoCount = 0; let threeCount = 0; const one = () => { return chain([ - () => { oneCount++; }, + () => { + oneCount++; + }, ]); }; const two = () => { return chain([ - () => { twoCount++; }, + () => { + twoCount++; + }, ]); }; const three = () => { @@ -127,7 +120,9 @@ describe('Schematic', () => { path: '/a/b/c', factory: () => { return chain([ - () => { chainCount++; }, + () => { + chainCount++; + }, one, two, three, @@ -135,16 +130,27 @@ describe('Schematic', () => { }, }; - const schematic = new SchematicImpl(desc, desc.factory, null !, engine); - schematic.call({}, observableOf(empty())) - .toPromise() - .then(_x => { - expect(chainCount).toBe(1); - expect(oneCount).toBe(1); - expect(twoCount).toBe(1); - expect(threeCount).toBe(1); - }) - .then(done, done.fail); + const schematic = new SchematicImpl(desc, desc.factory, null!, engine); + await lastValueFrom(schematic.call({}, observableOf(empty()))); + expect(chainCount).toBe(1); + expect(oneCount).toBe(1); + expect(twoCount).toBe(1); + expect(threeCount).toBe(1); }); + it('can be called with a scope', async () => { + const desc: SchematicDescription = { + collection, + name: 'test', + description: '', + path: '/a/b/c', + factory: () => (tree: Tree) => { + tree.create('a/b/c', 'some content'); + }, + }; + + const schematic = new SchematicImpl(desc, desc.factory, null!, engine); + const x = await lastValueFrom(schematic.call({}, observableOf(empty()), {}, { scope: 'base' })); + expect(files(x)).toEqual(['/base/a/b/c']); + }); }); diff --git a/packages/angular_devkit/schematics/src/exception/exception.ts b/packages/angular_devkit/schematics/src/exception/exception.ts index f7f419bfc145..7da97b665138 100644 --- a/packages/angular_devkit/schematics/src/exception/exception.ts +++ b/packages/angular_devkit/schematics/src/exception/exception.ts @@ -1,23 +1,26 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { BaseException } from '@angular-devkit/core'; +import { BaseException } from '@angular-devkit/core'; // Used by schematics to throw exceptions. export class SchematicsException extends BaseException {} - // Exceptions export class FileDoesNotExistException extends BaseException { - constructor(path: string) { super(`Path "${path}" does not exist.`); } + constructor(path: string) { + super(`Path "${path}" does not exist.`); + } } export class FileAlreadyExistException extends BaseException { - constructor(path: string) { super(`Path "${path}" already exist.`); } + constructor(path: string) { + super(`Path "${path}" already exist.`); + } } export class ContentHasMutatedException extends BaseException { constructor(path: string) { @@ -25,7 +28,9 @@ export class ContentHasMutatedException extends BaseException { } } export class InvalidUpdateRecordException extends BaseException { - constructor() { super(`Invalid record instance.`); } + constructor() { + super(`Invalid record instance.`); + } } export class MergeConflictException extends BaseException { constructor(path: string) { @@ -40,5 +45,7 @@ export class UnsuccessfulWorkflowExecution extends BaseException { } export class UnimplementedException extends BaseException { - constructor() { super('This function is unimplemented.'); } + constructor() { + super('This function is unimplemented.'); + } } diff --git a/packages/angular_devkit/schematics/src/formats/format-validator.ts b/packages/angular_devkit/schematics/src/formats/format-validator.ts index 863a5b3e214a..7b665c2b7314 100644 --- a/packages/angular_devkit/schematics/src/formats/format-validator.ts +++ b/packages/angular_devkit/schematics/src/formats/format-validator.ts @@ -1,27 +1,25 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { JsonObject, JsonValue, schema } from '@angular-devkit/core'; -import { Observable } from 'rxjs'; -import { mergeMap } from 'rxjs/operators'; +import { JsonObject, JsonValue, schema } from '@angular-devkit/core'; -export function formatValidator( +export async function formatValidator( data: JsonValue, dataSchema: JsonObject, formats: schema.SchemaFormat[], -): Observable { +): Promise { const registry = new schema.CoreSchemaRegistry(); for (const format of formats) { registry.addFormat(format); } - return registry - .compile(dataSchema) - .pipe(mergeMap(validator => validator(data))); + const validator = await registry.compile(dataSchema); + + return validator(data); } diff --git a/packages/angular_devkit/schematics/src/formats/html-selector.ts b/packages/angular_devkit/schematics/src/formats/html-selector.ts index 6d7d9ada9b0b..bc3efd019c29 100644 --- a/packages/angular_devkit/schematics/src/formats/html-selector.ts +++ b/packages/angular_devkit/schematics/src/formats/html-selector.ts @@ -1,22 +1,52 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { schema } from '@angular-devkit/core'; +// As per https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name +// * Without mandatory `-` as the application prefix will generally cover its inclusion +// * And an allowance for upper alpha characters -// Must start with a letter, and must contain only alphanumeric characters or dashes. -// When adding a dash the segment after the dash must also start with a letter. -export const htmlSelectorRe = /^[a-zA-Z][.0-9a-zA-Z]*(:?-[a-zA-Z][.0-9a-zA-Z]*)*$/; +// NOTE: This should eventually be broken out into two formats: full and partial (allows for prefix) + +const unicodeRanges = [ + [0xc0, 0xd6], + [0xd8, 0xf6], + [0xf8, 0x37d], + [0x37f, 0x1fff], + [0x200c, 0x200d], + [0x203f, 0x2040], + [0x2070, 0x218f], + [0x2c00, 0x2fef], + [0x3001, 0xd7ff], + [0xf900, 0xfdcf], + [0xfdf0, 0xfffd], + [0x10000, 0xeffff], +]; + +function isValidElementName(name: string) { + let regex = '^[a-zA-Z]['; + + regex += '-.0-9_a-zA-Z\\u{B7}'; + + for (const range of unicodeRanges) { + regex += `\\u{${range[0].toString(16)}}-\\u{${range[1].toString(16)}}`; + } + + regex += ']*$'; + + return new RegExp(regex, 'u').test(name); +} export const htmlSelectorFormat: schema.SchemaFormat = { name: 'html-selector', formatter: { async: false, - validate: (selector: string) => htmlSelectorRe.test(selector), + validate: (name: unknown) => typeof name === 'string' && isValidElementName(name), }, }; diff --git a/packages/angular_devkit/schematics/src/formats/html-selector_spec.ts b/packages/angular_devkit/schematics/src/formats/html-selector_spec.ts index c59ad62d6641..4327e32ca8e5 100644 --- a/packages/angular_devkit/schematics/src/formats/html-selector_spec.ts +++ b/packages/angular_devkit/schematics/src/formats/html-selector_spec.ts @@ -1,58 +1,62 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { map } from 'rxjs/operators'; import { formatValidator } from './format-validator'; import { htmlSelectorFormat } from './html-selector'; - describe('Schematics HTML selector format', () => { - it('accepts correct selectors', done => { + it('accepts correct selectors', async () => { const data = { selector: 'my-selector' }; const dataSchema = { properties: { selector: { type: 'string', format: 'html-selector' } }, }; - formatValidator(data, dataSchema, [htmlSelectorFormat]) - .pipe(map(result => expect(result.success).toBe(true))) - .toPromise().then(done, done.fail); + const result = await formatValidator(data, dataSchema, [htmlSelectorFormat]); + expect(result.success).toBeTrue(); }); - it('rejects selectors starting with invalid characters', done => { + it('rejects selectors starting with invalid characters', async () => { const data = { selector: 'my-selector$' }; const dataSchema = { properties: { selector: { type: 'string', format: 'html-selector' } }, }; - formatValidator(data, dataSchema, [htmlSelectorFormat]) - .pipe(map(result => expect(result.success).toBe(false))) - .toPromise().then(done, done.fail); + const result = await formatValidator(data, dataSchema, [htmlSelectorFormat]); + expect(result.success).toBeFalse(); }); - it('rejects selectors starting with number', done => { + it('rejects selectors starting with number', async () => { const data = { selector: '1selector' }; const dataSchema = { properties: { selector: { type: 'string', format: 'html-selector' } }, }; - formatValidator(data, dataSchema, [htmlSelectorFormat]) - .pipe(map(result => expect(result.success).toBe(false))) - .toPromise().then(done, done.fail); + const result = await formatValidator(data, dataSchema, [htmlSelectorFormat]); + expect(result.success).toBeFalse(); }); - it('rejects selectors with non-letter after dash', done => { + it('accepts selectors with non-letter after dash', async () => { const data = { selector: 'my-1selector' }; const dataSchema = { properties: { selector: { type: 'string', format: 'html-selector' } }, }; - formatValidator(data, dataSchema, [htmlSelectorFormat]) - .pipe(map(result => expect(result.success).toBe(false))) - .toPromise().then(done, done.fail); + const result = await formatValidator(data, dataSchema, [htmlSelectorFormat]); + expect(result.success).toBeTrue(); + }); + + it('accepts selectors with unicode', async () => { + const data = { selector: 'app-root😀' }; + const dataSchema = { + properties: { selector: { type: 'string', format: 'html-selector' } }, + }; + + const result = await formatValidator(data, dataSchema, [htmlSelectorFormat]); + expect(result.success).toBeTrue(); }); }); diff --git a/packages/angular_devkit/schematics/src/formats/index.ts b/packages/angular_devkit/schematics/src/formats/index.ts index 2cfab19db670..dff6a9035003 100644 --- a/packages/angular_devkit/schematics/src/formats/index.ts +++ b/packages/angular_devkit/schematics/src/formats/index.ts @@ -1,18 +1,16 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { schema } from '@angular-devkit/core'; import { htmlSelectorFormat } from './html-selector'; -export { htmlSelectorFormat } from './html-selector'; import { pathFormat } from './path'; + +export { htmlSelectorFormat } from './html-selector'; export { pathFormat } from './path'; -export const standardFormats: schema.SchemaFormat[] = [ - htmlSelectorFormat, - pathFormat, -]; +export const standardFormats: schema.SchemaFormat[] = [htmlSelectorFormat, pathFormat]; diff --git a/packages/angular_devkit/schematics/src/formats/path.ts b/packages/angular_devkit/schematics/src/formats/path.ts index 0c854957f15a..6261e4c67b2b 100644 --- a/packages/angular_devkit/schematics/src/formats/path.ts +++ b/packages/angular_devkit/schematics/src/formats/path.ts @@ -1,14 +1,13 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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, schema } from '@angular-devkit/core'; - export const pathFormat: schema.SchemaFormat = { name: 'path', formatter: { diff --git a/packages/angular_devkit/schematics/src/formats/path_spec.ts b/packages/angular_devkit/schematics/src/formats/path_spec.ts index 24901f08a72f..66ad815288ae 100644 --- a/packages/angular_devkit/schematics/src/formats/path_spec.ts +++ b/packages/angular_devkit/schematics/src/formats/path_spec.ts @@ -1,36 +1,32 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { map } from 'rxjs/operators'; import { formatValidator } from './format-validator'; import { pathFormat } from './path'; - describe('Schematics Path format', () => { - it('accepts correct Paths', done => { + it('accepts correct Paths', async () => { const data = { path: 'a/b/c' }; const dataSchema = { properties: { path: { type: 'string', format: 'path' } }, }; - formatValidator(data, dataSchema, [pathFormat]) - .pipe(map(result => expect(result.success).toBe(true))) - .toPromise().then(done, done.fail); + const result = await formatValidator(data, dataSchema, [pathFormat]); + expect(result.success).toBeTrue(); }); - it('rejects Paths that are not normalized', done => { + it('rejects Paths that are not normalized', async () => { const data = { path: 'a/b/c/../' }; const dataSchema = { properties: { path: { type: 'string', format: 'path' } }, }; - formatValidator(data, dataSchema, [pathFormat]) - .pipe(map(result => expect(result.success).toBe(false))) - .toPromise().then(done, done.fail); + const result = await formatValidator(data, dataSchema, [pathFormat]); + expect(result.success).toBeFalse(); }); }); diff --git a/packages/angular_devkit/schematics/src/index.ts b/packages/angular_devkit/schematics/src/index.ts index 31333a014f09..47363d0b9552 100644 --- a/packages/angular_devkit/schematics/src/index.ts +++ b/packages/angular_devkit/schematics/src/index.ts @@ -1,18 +1,22 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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'; +import * as formats from './formats/index'; import { FilePredicate, MergeStrategy, Tree as TreeInterface } from './tree/interface'; -import { branch, empty, merge, optimize, partition } from './tree/static'; +import { branch, empty, merge, partition } from './tree/static'; +import * as workflow from './workflow/index'; export { SchematicsException } from './exception/exception'; export * from './tree/action'; -export * from './engine'; +export * from './engine/index'; export * from './exception/exception'; export * from './tree/interface'; export * from './rules/base'; @@ -25,20 +29,12 @@ export * from './rules/url'; export * from './tree/delegate'; export * from './tree/empty'; export * from './tree/host-tree'; -export * from './tree/filesystem'; -export * from './tree/virtual'; -export {UpdateRecorder} from './tree/interface'; +export type { UpdateRecorder } from './tree/interface'; export * from './engine/schematic'; export * from './sink/dryrun'; -export * from './sink/filesystem'; export * from './sink/host'; export * from './sink/sink'; - -import * as formats from './formats'; -export { formats }; - -import * as workflow from './workflow'; -export { workflow }; +export { formats, strings, workflow }; export interface TreeConstructor { empty(): TreeInterface; @@ -50,15 +46,23 @@ export interface TreeConstructor { export type Tree = TreeInterface; export const Tree: TreeConstructor = { - empty() { return empty(); }, - branch(tree: TreeInterface) { return branch(tree); }, - merge(tree: TreeInterface, - other: TreeInterface, - strategy: MergeStrategy = MergeStrategy.Default) { + empty() { + return empty(); + }, + branch(tree: TreeInterface) { + return branch(tree); + }, + merge( + tree: TreeInterface, + other: TreeInterface, + strategy: MergeStrategy = MergeStrategy.Default, + ) { return merge(tree, other, strategy); }, partition(tree: TreeInterface, predicate: FilePredicate) { return partition(tree, predicate); }, - optimize(tree: TreeInterface) { return optimize(tree); }, + optimize(tree: TreeInterface) { + return tree; + }, }; diff --git a/packages/angular_devkit/schematics/src/rules/base.ts b/packages/angular_devkit/schematics/src/rules/base.ts index 2be938161ad3..7c85e5678cbd 100644 --- a/packages/angular_devkit/schematics/src/rules/base.ts +++ b/packages/angular_devkit/schematics/src/rules/base.ts @@ -1,28 +1,21 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { Observable, of as observableOf } from 'rxjs'; -import { concatMap, last, map } from 'rxjs/operators'; -import { FileOperator, Rule, SchematicContext, Source } from '../engine/interface'; + +import { concat, lastValueFrom, map, mapTo, toArray } from 'rxjs'; +import { FileOperator, Rule, Source } from '../engine/interface'; import { SchematicsException } from '../exception/exception'; -import { FilteredTree } from '../tree/filtered'; +import { DelegateTree } from '../tree/delegate'; import { FilterHostTree, HostTree } from '../tree/host-tree'; import { FileEntry, FilePredicate, MergeStrategy, Tree } from '../tree/interface'; -import { - branch, - empty as staticEmpty, - merge as staticMerge, - optimize as staticOptimize, - partition as staticPartition, -} from '../tree/static'; -import { VirtualTree } from '../tree/virtual'; +import { ScopedTree } from '../tree/scoped'; +import { partition, empty as staticEmpty } from '../tree/static'; import { callRule, callSource } from './call'; - /** * A Source that returns an tree as its single value. */ @@ -30,7 +23,6 @@ export function source(tree: Tree): Source { return () => tree; } - /** * A source that returns an empty tree. */ @@ -38,93 +30,76 @@ export function empty(): Source { return () => staticEmpty(); } - /** * Chain multiple rules into a single rule. */ -export function chain(rules: Rule[]): Rule { - return (tree: Tree, context: SchematicContext) => { - return rules.reduce((acc: Observable, curr: Rule) => { - return callRule(curr, acc, context); - }, observableOf(tree)); +export function chain(rules: Iterable | AsyncIterable): Rule { + return async (initialTree, context) => { + let intermediateTree = initialTree; + if (Symbol.asyncIterator in rules) { + for await (const rule of rules) { + intermediateTree = await lastValueFrom(callRule(rule, intermediateTree, context)); + } + } else { + for (const rule of rules) { + intermediateTree = await lastValueFrom(callRule(rule, intermediateTree, context)); + } + } + + return intermediateTree; }; } - /** * Apply multiple rules to a source, and returns the source transformed. */ export function apply(source: Source, rules: Rule[]): Source { - return (context: SchematicContext) => { - return callRule(chain([ - ...rules, - // Optimize the tree. Since this is a source tree, there's not much harm here and this might - // avoid further issues. - tree => { - if (tree instanceof VirtualTree) { - tree.optimize(); - - return tree; - } else if (tree.actions.length != 0) { - return staticOptimize(tree); - } else { - return tree; - } - }, - ]), callSource(source, context), context); - }; + return (context) => callRule(chain(rules), callSource(source, context), context); } - /** * Merge an input tree with the source passed in. */ export function mergeWith(source: Source, strategy: MergeStrategy = MergeStrategy.Default): Rule { - return (tree: Tree, context: SchematicContext) => { - const result = callSource(source, context); - - return result.pipe(map(other => staticMerge(tree, other, strategy || context.strategy))); + return (tree, context) => { + return callSource(source, context).pipe( + map((sourceTree) => tree.merge(sourceTree, strategy || context.strategy)), + mapTo(tree), + ); }; } - export function noop(): Rule { - return (tree: Tree, _context: SchematicContext) => tree; + return () => {}; } - export function filter(predicate: FilePredicate): Rule { - return ((tree: Tree) => { - // TODO: Remove VirtualTree usage in 7.0 - if (VirtualTree.isVirtualTree(tree)) { - return new FilteredTree(tree, predicate); - } else if (HostTree.isHostTree(tree)) { + return (tree: Tree) => { + if (tree instanceof DelegateTree) { + tree = tree.branch(); + } + + if (HostTree.isHostTree(tree)) { return new FilterHostTree(tree, predicate); } else { throw new SchematicsException('Tree type is not supported.'); } - }); + }; } - export function asSource(rule: Rule): Source { - return apply(empty(), [rule]); + return (context) => callRule(rule, staticEmpty(), context); } - -export function branchAndMerge(rule: Rule, strategy = MergeStrategy.Default): Rule { - return (tree: Tree, context: SchematicContext) => { - const branchedTree = branch(tree); - - return callRule(rule, observableOf(branchedTree), context) - .pipe( - last(), - map(t => staticMerge(tree, t, strategy)), - ); +export function branchAndMerge(rule: Rule, strategy: MergeStrategy = MergeStrategy.Default): Rule { + return (tree, context) => { + return callRule(rule, tree.branch(), context).pipe( + map((branch) => tree.merge(branch, strategy || context.strategy)), + mapTo(tree), + ); }; } - export function when(predicate: FilePredicate, operator: FileOperator): FileOperator { return (entry: FileEntry) => { if (predicate(entry.path, entry)) { @@ -135,30 +110,25 @@ export function when(predicate: FilePredicate, operator: FileOperator): }; } - export function partitionApplyMerge( predicate: FilePredicate, ruleYes: Rule, ruleNo?: Rule, ): Rule { - return (tree: Tree, context: SchematicContext) => { - const [yes, no] = staticPartition(tree, predicate); + return (tree, context) => { + const [yes, no] = partition(tree, predicate); - if (!ruleNo) { - // Shortcut. - return callRule(ruleYes, observableOf(staticPartition(tree, predicate)[0]), context) - .pipe(map(yesTree => staticMerge(yesTree, no, context.strategy))); - } + return concat(callRule(ruleYes, yes, context), callRule(ruleNo || noop(), no, context)).pipe( + toArray(), + map(([yesTree, noTree]) => { + yesTree.merge(noTree, context.strategy); - return callRule(ruleYes, observableOf(yes), context) - .pipe(concatMap(yesTree => { - return callRule(ruleNo, observableOf(no), context) - .pipe(map(noTree => staticMerge(yesTree, noTree, context.strategy))); - })); + return yesTree; + }), + ); }; } - export function forEach(operator: FileOperator): Rule { return (tree: Tree) => { tree.visit((path, entry) => { @@ -181,12 +151,9 @@ export function forEach(operator: FileOperator): Rule { tree.overwrite(newEntry.path, newEntry.content); } }); - - return tree; }; } - export function composeFileOperators(operators: FileOperator[]): FileOperator { return (entry: FileEntry) => { let current: FileEntry | null = entry; @@ -202,3 +169,21 @@ export function composeFileOperators(operators: FileOperator[]): FileOperator { return current; }; } + +export function applyToSubtree(path: string, rules: Rule[]): Rule { + return (tree, context) => { + const scoped = new ScopedTree(tree, path); + + return callRule(chain(rules), scoped, context).pipe( + map((result) => { + if (result === scoped) { + return tree; + } else { + throw new SchematicsException( + 'Original tree must be returned from all rules when using "applyToSubtree".', + ); + } + }), + ); + }; +} diff --git a/packages/angular_devkit/schematics/src/rules/base_spec.ts b/packages/angular_devkit/schematics/src/rules/base_spec.ts index b504d3c01db1..94bbd228ce49 100644 --- a/packages/angular_devkit/schematics/src/rules/base_spec.ts +++ b/packages/angular_devkit/schematics/src/rules/base_spec.ts @@ -1,25 +1,21 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -// tslint:disable:no-implicit-dependencies -// tslint:disable:no-non-null-assertion + import { Path, virtualFs } from '@angular-devkit/core'; -import { - HostTree, - MergeStrategy, - partitionApplyMerge, -} from '@angular-devkit/schematics'; -import { of as observableOf } from 'rxjs'; +import { lastValueFrom, of as observableOf } from 'rxjs'; +import { UnitTestTree } from '../../testing'; import { Rule, SchematicContext, Source } from '../engine/interface'; -import { Tree } from '../tree/interface'; +import { HostTree } from '../tree/host-tree'; +import { MergeStrategy, Tree } from '../tree/interface'; import { empty } from '../tree/static'; -import { apply, chain } from './base'; +import { apply, applyToSubtree, chain, filter, partitionApplyMerge } from './base'; import { callRule, callSource } from './call'; - +import { move } from './move'; const context: SchematicContext = { engine: null, @@ -27,9 +23,8 @@ const context: SchematicContext = { strategy: MergeStrategy.Default, } as {} as SchematicContext; - describe('chain', () => { - it('works with simple rules', done => { + it('works with simple rules', async () => { const rulesCalled: Tree[] = []; const tree0 = empty(); @@ -37,23 +32,21 @@ describe('chain', () => { const tree2 = empty(); const tree3 = empty(); - const rule0: Rule = (tree: Tree) => (rulesCalled[0] = tree, tree1); - const rule1: Rule = (tree: Tree) => (rulesCalled[1] = tree, tree2); - const rule2: Rule = (tree: Tree) => (rulesCalled[2] = tree, tree3); - - callRule(chain([ rule0, rule1, rule2 ]), observableOf(tree0), context) - .toPromise() - .then(result => { - expect(result).not.toBe(tree0); - expect(rulesCalled[0]).toBe(tree0); - expect(rulesCalled[1]).toBe(tree1); - expect(rulesCalled[2]).toBe(tree2); - expect(result).toBe(tree3); - }) - .then(done, done.fail); + const rule0: Rule = (tree: Tree) => ((rulesCalled[0] = tree), tree1); + const rule1: Rule = (tree: Tree) => ((rulesCalled[1] = tree), tree2); + const rule2: Rule = (tree: Tree) => ((rulesCalled[2] = tree), tree3); + + const result = await lastValueFrom( + callRule(chain([rule0, rule1, rule2]), observableOf(tree0), context), + ); + expect(result).not.toBe(tree0); + expect(rulesCalled[0]).toBe(tree0); + expect(rulesCalled[1]).toBe(tree1); + expect(rulesCalled[2]).toBe(tree2); + expect(result).toBe(tree3); }); - it('works with observable rules', done => { + it('works with async rules', async () => { const rulesCalled: Tree[] = []; const tree0 = empty(); @@ -61,79 +54,152 @@ describe('chain', () => { const tree2 = empty(); const tree3 = empty(); - const rule0: Rule = (tree: Tree) => (rulesCalled[0] = tree, observableOf(tree1)); - const rule1: Rule = (tree: Tree) => (rulesCalled[1] = tree, observableOf(tree2)); - const rule2: Rule = (tree: Tree) => (rulesCalled[2] = tree, tree3); - - callRule(chain([ rule0, rule1, rule2 ]), observableOf(tree0), context) - .toPromise() - .then(result => { - expect(result).not.toBe(tree0); - expect(rulesCalled[0]).toBe(tree0); - expect(rulesCalled[1]).toBe(tree1); - expect(rulesCalled[2]).toBe(tree2); - expect(result).toBe(tree3); - }) - .then(done, done.fail); + const rule0: Rule = async (tree: Tree) => ((rulesCalled[0] = tree), tree1); + const rule1: Rule = async (tree: Tree) => ((rulesCalled[1] = tree), tree2); + const rule2: Rule = async (tree: Tree) => ((rulesCalled[2] = tree), tree3); + + const result = await lastValueFrom(callRule(chain([rule0, rule1, rule2]), tree0, context)); + expect(result).not.toBe(tree0); + expect(rulesCalled[0]).toBe(tree0); + expect(rulesCalled[1]).toBe(tree1); + expect(rulesCalled[2]).toBe(tree2); + expect(result).toBe(tree3); }); -}); -describe('apply', () => { - it('works with simple rules', done => { + it('works with a sync generator of rules', async () => { const rulesCalled: Tree[] = []; - let sourceCalled = false; const tree0 = empty(); const tree1 = empty(); const tree2 = empty(); const tree3 = empty(); - const source: Source = () => (sourceCalled = true, tree0); - const rule0: Rule = (tree: Tree) => (rulesCalled[0] = tree, tree1); - const rule1: Rule = (tree: Tree) => (rulesCalled[1] = tree, tree2); - const rule2: Rule = (tree: Tree) => (rulesCalled[2] = tree, tree3); - - callSource(apply(source, [ rule0, rule1, rule2 ]), context) - .toPromise() - .then(result => { - expect(result).not.toBe(tree0); - expect(rulesCalled[0]).toBe(tree0); - expect(rulesCalled[1]).toBe(tree1); - expect(rulesCalled[2]).toBe(tree2); - expect(result).toBe(tree3); - }) - .then(done, done.fail); + const rule0: Rule = (tree: Tree) => ((rulesCalled[0] = tree), tree1); + const rule1: Rule = (tree: Tree) => ((rulesCalled[1] = tree), tree2); + const rule2: Rule = (tree: Tree) => ((rulesCalled[2] = tree), tree3); + + function* generateRules() { + yield rule0; + yield rule1; + yield rule2; + } + + const result = await lastValueFrom(callRule(chain(generateRules()), tree0, context)); + + expect(result).not.toBe(tree0); + expect(rulesCalled[0]).toBe(tree0); + expect(rulesCalled[1]).toBe(tree1); + expect(rulesCalled[2]).toBe(tree2); + expect(result).toBe(tree3); }); - it('works with observable rules', done => { + it('works with an async generator of rules', async () => { const rulesCalled: Tree[] = []; - let sourceCalled = false; const tree0 = empty(); const tree1 = empty(); const tree2 = empty(); const tree3 = empty(); - const source: Source = () => (sourceCalled = true, tree0); - const rule0: Rule = (tree: Tree) => (rulesCalled[0] = tree, observableOf(tree1)); - const rule1: Rule = (tree: Tree) => (rulesCalled[1] = tree, observableOf(tree2)); - const rule2: Rule = (tree: Tree) => (rulesCalled[2] = tree, tree3); - - callSource(apply(source, [ rule0, rule1, rule2 ]), context) - .toPromise() - .then(result => { - expect(result).not.toBe(tree0); - expect(rulesCalled[0]).toBe(tree0); - expect(rulesCalled[1]).toBe(tree1); - expect(rulesCalled[2]).toBe(tree2); - expect(result).toBe(tree3); - }) - .then(done, done.fail); + const rule0: Rule = (tree: Tree) => ((rulesCalled[0] = tree), tree1); + const rule1: Rule = (tree: Tree) => ((rulesCalled[1] = tree), tree2); + const rule2: Rule = (tree: Tree) => ((rulesCalled[2] = tree), tree3); + + async function* generateRules() { + yield rule0; + yield rule1; + yield rule2; + } + + const result = await lastValueFrom(callRule(chain(generateRules()), tree0, context)); + + expect(result).not.toBe(tree0); + expect(rulesCalled[0]).toBe(tree0); + expect(rulesCalled[1]).toBe(tree1); + expect(rulesCalled[2]).toBe(tree2); + expect(result).toBe(tree3); + }); + + it('works with observable rules', async () => { + const rulesCalled: Tree[] = []; + + const tree0 = empty(); + const tree1 = empty(); + const tree2 = empty(); + const tree3 = empty(); + + const rule0: Rule = (tree: Tree) => ((rulesCalled[0] = tree), observableOf(tree1)); + const rule1: Rule = (tree: Tree) => ((rulesCalled[1] = tree), observableOf(tree2)); + const rule2: Rule = (tree: Tree) => ((rulesCalled[2] = tree), tree3); + + const result = await lastValueFrom( + callRule(chain([rule0, rule1, rule2]), observableOf(tree0), context), + ); + expect(result).not.toBe(tree0); + expect(rulesCalled[0]).toBe(tree0); + expect(rulesCalled[1]).toBe(tree1); + expect(rulesCalled[2]).toBe(tree2); + expect(result).toBe(tree3); + }); + + it('works with a large number of rules', async () => { + const tree0 = empty(); + let callCount = 0; + const rules: Rule[] = Array.from({ length: 20_000 }, () => () => { + callCount++; + }); + + const result = await lastValueFrom(callRule(chain(rules), tree0, context)); + + expect(result).toBe(tree0); + expect(callCount).toBe(20_000); + }); +}); + +describe('apply', () => { + it('works with simple rules', async () => { + const rulesCalled: Tree[] = []; + const tree0 = empty(); + const tree1 = empty(); + const tree2 = empty(); + const tree3 = empty(); + + const source: Source = () => tree0; + const rule0: Rule = (tree: Tree) => ((rulesCalled[0] = tree), tree1); + const rule1: Rule = (tree: Tree) => ((rulesCalled[1] = tree), tree2); + const rule2: Rule = (tree: Tree) => ((rulesCalled[2] = tree), tree3); + + const result = await lastValueFrom(callSource(apply(source, [rule0, rule1, rule2]), context)); + expect(result).not.toBe(tree0); + expect(rulesCalled[0]).toBe(tree0); + expect(rulesCalled[1]).toBe(tree1); + expect(rulesCalled[2]).toBe(tree2); + expect(result).toBe(tree3); + }); + + it('works with observable rules', async () => { + const rulesCalled: Tree[] = []; + const tree0 = empty(); + const tree1 = empty(); + const tree2 = empty(); + const tree3 = empty(); + + const source: Source = () => tree0; + const rule0: Rule = (tree: Tree) => ((rulesCalled[0] = tree), observableOf(tree1)); + const rule1: Rule = (tree: Tree) => ((rulesCalled[1] = tree), observableOf(tree2)); + const rule2: Rule = (tree: Tree) => ((rulesCalled[2] = tree), tree3); + + const result = await lastValueFrom(callSource(apply(source, [rule0, rule1, rule2]), context)); + expect(result).not.toBe(tree0); + expect(rulesCalled[0]).toBe(tree0); + expect(rulesCalled[1]).toBe(tree1); + expect(rulesCalled[2]).toBe(tree2); + expect(result).toBe(tree3); }); }); describe('partitionApplyMerge', () => { - it('works with simple rules', done => { + it('works with simple rules', async () => { const host = new virtualFs.test.TestHost({ '/test1': '', '/test2': '', @@ -154,12 +220,86 @@ describe('partitionApplyMerge', () => { return empty(); }; - callRule(partitionApplyMerge(predicate, ruleYes, ruleNo), observableOf(tree), context) - .toPromise() - .then(result => { - expect(result.exists('/test1')).toBe(false); - expect(result.exists('/test2')).toBe(false); - }) - .then(done, done.fail); + const result = await lastValueFrom( + callRule(partitionApplyMerge(predicate, ruleYes, ruleNo), observableOf(tree), context), + ); + expect(result.exists('/test1')).toBe(false); + expect(result.exists('/test2')).toBe(false); + }); + + it('works with a UnitTestTree', async () => { + const tree = new HostTree(); + tree.create('/test1', ''); + tree.create('/test2', ''); + const predicate = (path: Path) => path.indexOf('1') != -1; + + const ruleYes: Rule = (tree: Tree) => { + expect(tree.exists('/test1')).toBe(true); + expect(tree.exists('/test2')).toBe(false); + }; + const ruleNo: Rule = (tree: Tree) => { + expect(tree.exists('/test1')).toBe(false); + expect(tree.exists('/test2')).toBe(true); + }; + + const result = await lastValueFrom( + callRule( + partitionApplyMerge(predicate, ruleYes, ruleNo), + observableOf(new UnitTestTree(new UnitTestTree(tree))), + context, + ), + ); + expect(result.exists('/test1')).toBe(true); + expect(result.exists('/test2')).toBe(true); + }); +}); + +describe('applyToSubtree', () => { + it('works', async () => { + const tree = new HostTree(); + tree.create('a/b/file1', 'hello world'); + tree.create('a/b/file2', 'hello world'); + tree.create('a/c/file3', 'hello world'); + + const result = await lastValueFrom( + callRule(applyToSubtree('a/b', [move('x')]), observableOf(tree), context), + ); + expect(result.exists('a/b/x/file1')).toBe(true); + expect(result.exists('a/b/x/file2')).toBe(true); + expect(result.exists('a/c/file3')).toBe(true); + }); +}); + +describe('filter', () => { + it('works with a HostTree', async () => { + const tree = new HostTree(); + tree.create('/file1.ts', ''); + tree.create('/file2.js', ''); + + const result = await lastValueFrom( + callRule( + filter((path) => path.endsWith('.ts')), + observableOf(tree), + context, + ), + ); + expect(result.exists('/file1.ts')).toBe(true); + expect(result.exists('/file2.js')).toBe(false); + }); + + it('works with a UnitTestTree', async () => { + const tree = new HostTree(); + tree.create('/file1.ts', ''); + tree.create('/file2.js', ''); + + const result = await lastValueFrom( + callRule( + filter((path) => path.endsWith('.ts')), + observableOf(new UnitTestTree(new UnitTestTree(tree))), + context, + ), + ); + expect(result.exists('/file1.ts')).toBe(true); + expect(result.exists('/file2.js')).toBe(false); }); }); diff --git a/packages/angular_devkit/schematics/src/rules/call.ts b/packages/angular_devkit/schematics/src/rules/call.ts index db5faffb21a6..0bc86ad0c352 100644 --- a/packages/angular_devkit/schematics/src/rules/call.ts +++ b/packages/angular_devkit/schematics/src/rules/call.ts @@ -1,17 +1,16 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { BaseException, isObservable } from '@angular-devkit/core'; -import { Observable, of as observableOf, throwError } from 'rxjs'; -import { defaultIfEmpty, last, mergeMap, tap } from 'rxjs/operators'; + +import { BaseException } from '@angular-devkit/core'; +import { Observable, defaultIfEmpty, defer, isObservable, lastValueFrom, mergeMap } from 'rxjs'; import { Rule, SchematicContext, Source } from '../engine/interface'; import { Tree, TreeSymbol } from '../tree/interface'; - function _getTypeOfResult(value?: {}): string { if (value === undefined) { return 'undefined'; @@ -32,7 +31,6 @@ function _getTypeOfResult(value?: {}): string { } } - /** * When a rule or source returns an invalid value. */ @@ -42,64 +40,59 @@ export class InvalidRuleResultException extends BaseException { } } - export class InvalidSourceResultException extends BaseException { constructor(value?: {}) { super(`Invalid source result: ${_getTypeOfResult(value)}.`); } } - export function callSource(source: Source, context: SchematicContext): Observable { - const result = source(context); + return defer(async () => { + let result: Tree | Observable | undefined = source(context); - if (isObservable(result)) { - // Only return the last Tree, and make sure it's a Tree. - return result.pipe( - defaultIfEmpty(), - last(), - tap(inner => { - if (!inner || !(TreeSymbol in inner)) { - throw new InvalidSourceResultException(inner); - } - }), - ); - } else if (result && TreeSymbol in result) { - return observableOf(result); - } else { - return throwError(new InvalidSourceResultException(result)); - } -} + if (isObservable(result)) { + result = await lastValueFrom(result.pipe(defaultIfEmpty(undefined))); + } + if (result && TreeSymbol in result) { + return result; + } + + throw new InvalidSourceResultException(result); + }); +} export function callRule( rule: Rule, - input: Observable, + input: Tree | Observable, context: SchematicContext, ): Observable { - return input.pipe(mergeMap(inputTree => { - const result = rule(inputTree, context); + if (isObservable(input)) { + return input.pipe(mergeMap((inputTree) => callRuleAsync(rule, inputTree, context))); + } else { + return defer(() => callRuleAsync(rule, input, context)); + } +} - if (!result) { - return observableOf(inputTree); - } else if (typeof result == 'function') { - // This is considered a Rule, chain the rule and return its output. - return callRule(result, observableOf(inputTree), context); - } else if (isObservable(result)) { - // Only return the last Tree, and make sure it's a Tree. - return result.pipe( - defaultIfEmpty(), - last(), - tap(inner => { - if (!inner || !(TreeSymbol in inner)) { - throw new InvalidRuleResultException(inner); - } - }), - ); - } else if (TreeSymbol in result) { - return observableOf(result); - } else { - return throwError(new InvalidRuleResultException(result)); - } - })); +async function callRuleAsync(rule: Rule, tree: Tree, context: SchematicContext): Promise { + let result = await rule(tree, context); + + while (typeof result === 'function') { + // This is considered a Rule, chain the rule and return its output. + result = await result(tree, context); + } + + if (typeof result === 'undefined') { + return tree; + } + + if (isObservable(result)) { + result = await lastValueFrom(result.pipe(defaultIfEmpty(tree))); + } + + if (result && TreeSymbol in result) { + return result; + } + + throw new InvalidRuleResultException(result); } diff --git a/packages/angular_devkit/schematics/src/rules/call_spec.ts b/packages/angular_devkit/schematics/src/rules/call_spec.ts index b4ec2d4b43e5..3d9a7c8ce2ea 100644 --- a/packages/angular_devkit/schematics/src/rules/call_spec.ts +++ b/packages/angular_devkit/schematics/src/rules/call_spec.ts @@ -1,17 +1,15 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -// tslint:disable:no-non-null-assertion -// tslint:disable:no-any -// tslint:disable:no-implicit-dependencies -import { MergeStrategy } from '@angular-devkit/schematics'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ import { of as observableOf } from 'rxjs'; import { Rule, SchematicContext, Source } from '../engine/interface'; -import { Tree } from '../tree/interface'; +import { MergeStrategy, Tree } from '../tree/interface'; import { empty } from '../tree/static'; import { InvalidRuleResultException, @@ -20,131 +18,87 @@ import { callSource, } from './call'; - const context: SchematicContext = { engine: null, debug: false, strategy: MergeStrategy.Default, } as {} as SchematicContext; - describe('callSource', () => { - it('errors if undefined source', done => { + it('errors if undefined source', async () => { const source0: any = () => undefined; - callSource(source0, context) - .toPromise() - .then(() => done.fail(), err => { - expect(err).toEqual(new InvalidSourceResultException()); - }) - .then(done, done.fail); + await expectAsync(callSource(source0, context).toPromise()).toBeRejectedWithError( + InvalidSourceResultException, + ); }); - it('errors if invalid source object', done => { - const source0: Source = () => ({} as Tree); + it('errors if invalid source object', async () => { + const source0: Source = () => ({}) as Tree; - callSource(source0, context) - .toPromise() - .then(() => done.fail(), err => { - expect(err).toEqual(new InvalidSourceResultException({})); - }) - .then(done, done.fail); + await expectAsync(callSource(source0, context).toPromise()).toBeRejectedWithError( + InvalidSourceResultException, + ); }); - it('errors if Observable of invalid source object', done => { + it('errors if Observable of invalid source object', async () => { const source0: Source = () => observableOf({} as Tree); - callSource(source0, context) - .toPromise() - .then(() => done.fail(), err => { - expect(err).toEqual(new InvalidSourceResultException({})); - }) - .then(done, done.fail); + await expectAsync(callSource(source0, context).toPromise()).toBeRejectedWithError( + InvalidSourceResultException, + ); }); - it('works with a Tree', done => { + it('works with a Tree', async () => { const tree0 = empty(); const source0: Source = () => tree0; - - callSource(source0, context) - .toPromise() - .then(tree => { - expect(tree).toBe(tree0); - }) - .then(done, done.fail); + const tree = await callSource(source0, context).toPromise(); + expect(tree).toBe(tree0); }); - it('works with an Observable', done => { + it('works with an Observable', async () => { const tree0 = empty(); const source0: Source = () => observableOf(tree0); - - callSource(source0, context) - .toPromise() - .then(tree => { - expect(tree).toBe(tree0); - }) - .then(done, done.fail); + const tree = await callSource(source0, context).toPromise(); + expect(tree).toBe(tree0); }); }); describe('callRule', () => { - it('errors if invalid source object', done => { - const tree0 = observableOf(empty()); - const rule0: Rule = () => ({} as Tree); - - callRule(rule0, tree0, context) - .toPromise() - .then(() => done.fail(), err => { - expect(err).toEqual(new InvalidRuleResultException({})); - }) - .then(done, done.fail); + it('should throw InvalidRuleResultException when rule result is non-Tree object', async () => { + const rule0: Rule = () => ({}) as Tree; + + await expectAsync(callRule(rule0, empty(), context).toPromise()).toBeRejectedWithError( + InvalidRuleResultException, + ); }); - it('errors if Observable of invalid source object', done => { - const tree0 = observableOf(empty()); - const rule0: Rule = () => observableOf({} as Tree); + it('should throw InvalidRuleResultException when rule result is null', async () => { + const rule0: Rule = () => null as unknown as Tree; - callRule(rule0, tree0, context) - .toPromise() - .then(() => done.fail(), err => { - expect(err).toEqual(new InvalidRuleResultException({})); - }) - .then(done, done.fail); + await expectAsync(callRule(rule0, empty(), context).toPromise()).toBeRejectedWithError( + InvalidRuleResultException, + ); }); - it('works with undefined result', done => { + it('works with undefined result', async () => { const tree0 = empty(); const rule0: Rule = () => undefined; - - callRule(rule0, observableOf(tree0), context) - .toPromise() - .then(tree => { - expect(tree).toBe(tree0); - }) - .then(done, done.fail); + const tree = await callRule(rule0, observableOf(tree0), context).toPromise(); + expect(tree).toBe(tree0); }); - it('works with a Tree', done => { + it('works with a Tree', async () => { const tree0 = empty(); const rule0: Rule = () => tree0; - - callRule(rule0, observableOf(tree0), context) - .toPromise() - .then(tree => { - expect(tree).toBe(tree0); - }) - .then(done, done.fail); + const tree = await callRule(rule0, observableOf(tree0), context).toPromise(); + expect(tree).toBe(tree0); }); - it('works with an Observable', done => { + it('works with an Observable', async () => { const tree0 = empty(); const rule0: Rule = () => observableOf(tree0); - - callRule(rule0, observableOf(tree0), context) - .toPromise() - .then(tree => { - expect(tree).toBe(tree0); - }) - .then(done, done.fail); + const tree = await callRule(rule0, observableOf(tree0), context).toPromise(); + expect(tree).toBe(tree0); }); }); diff --git a/packages/angular_devkit/schematics/src/rules/move.ts b/packages/angular_devkit/schematics/src/rules/move.ts index 96cfe15737fc..4c6c1e8d2f39 100644 --- a/packages/angular_devkit/schematics/src/rules/move.ts +++ b/packages/angular_devkit/schematics/src/rules/move.ts @@ -1,15 +1,17 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { join, normalize } from '@angular-devkit/core'; import { Rule } from '../engine/interface'; import { noop } from './base'; - +export function move(from: string, to: string): Rule; +export function move(to: string): Rule; export function move(from: string, to?: string): Rule { if (to === undefined) { to = from; @@ -23,9 +25,17 @@ export function move(from: string, to?: string): Rule { return noop; } - return tree => tree.visit(path => { - if (path.startsWith(fromPath)) { - tree.rename(path, toPath + '/' + path.substr(fromPath.length)); + return (tree) => { + if (tree.exists(fromPath)) { + // fromPath is a file + tree.rename(fromPath, toPath); + } else { + // fromPath is a directory + tree.getDir(fromPath).visit((path) => { + tree.rename(path, join(toPath, path.slice(fromPath.length))); + }); } - }); + + return tree; + }; } diff --git a/packages/angular_devkit/schematics/src/rules/move_spec.ts b/packages/angular_devkit/schematics/src/rules/move_spec.ts index 7737f861db1d..10a33c693d43 100644 --- a/packages/angular_devkit/schematics/src/rules/move_spec.ts +++ b/packages/angular_devkit/schematics/src/rules/move_spec.ts @@ -1,67 +1,80 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -// tslint:disable:no-non-null-assertion -import { of as observableOf } from 'rxjs'; + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { lastValueFrom, of as observableOf } from 'rxjs'; import { SchematicContext } from '../engine/interface'; import { HostTree } from '../tree/host-tree'; import { callRule } from './call'; import { move } from './move'; +const context: SchematicContext = null!; -const context: SchematicContext = null !; +describe('move', () => { + it('works on moving the whole structure', async () => { + const tree = new HostTree(); + tree.create('a/b/file1', 'hello world'); + tree.create('a/b/file2', 'hello world'); + tree.create('a/c/file3', 'hello world'); + const result = await lastValueFrom(callRule(move('sub'), observableOf(tree), context)); + expect(result.exists('sub/a/b/file1')).toBe(true); + expect(result.exists('sub/a/b/file2')).toBe(true); + expect(result.exists('sub/a/c/file3')).toBe(true); + }); -describe('move', () => { - it('works on moving the whole structure', done => { + it('works on moving a subdirectory structure', async () => { + const tree = new HostTree(); + tree.create('a/b/file1', 'hello world'); + tree.create('a/b/file2', 'hello world'); + tree.create('a/c/file3', 'hello world'); + + const result = await lastValueFrom(callRule(move('a/b', 'sub'), observableOf(tree), context)); + expect(result.exists('sub/file1')).toBe(true); + expect(result.exists('sub/file2')).toBe(true); + expect(result.exists('a/c/file3')).toBe(true); + }); + + it('works on moving a directory into a subdirectory of itself', async () => { const tree = new HostTree(); tree.create('a/b/file1', 'hello world'); tree.create('a/b/file2', 'hello world'); tree.create('a/c/file3', 'hello world'); - callRule(move('sub'), observableOf(tree), context) - .toPromise() - .then(result => { - expect(result.exists('sub/a/b/file1')).toBe(true); - expect(result.exists('sub/a/b/file2')).toBe(true); - expect(result.exists('sub/a/c/file3')).toBe(true); - }) - .then(done, done.fail); + const result = await lastValueFrom(callRule(move('a/b', 'a/b/c'), observableOf(tree), context)); + expect(result.exists('a/b/c/file1')).toBe(true); + expect(result.exists('a/b/c/file2')).toBe(true); + expect(result.exists('a/c/file3')).toBe(true); }); - it('works on moving a subdirectory structure', done => { + it('works on moving a directory into a parent of itself', async () => { const tree = new HostTree(); tree.create('a/b/file1', 'hello world'); tree.create('a/b/file2', 'hello world'); tree.create('a/c/file3', 'hello world'); - callRule(move('a/b', 'sub'), observableOf(tree), context) - .toPromise() - .then(result => { - expect(result.exists('sub/file1')).toBe(true); - expect(result.exists('sub/file2')).toBe(true); - expect(result.exists('a/c/file3')).toBe(true); - }) - .then(done, done.fail); + const result = await lastValueFrom(callRule(move('a/b', 'a'), observableOf(tree), context)); + expect(result.exists('file1')).toBe(false); + expect(result.exists('file2')).toBe(false); + expect(result.exists('a/file1')).toBe(true); + expect(result.exists('a/file2')).toBe(true); + expect(result.exists('a/c/file3')).toBe(true); }); - it('becomes a noop with identical from and to', done => { + it('becomes a noop with identical from and to', async () => { const tree = new HostTree(); tree.create('a/b/file1', 'hello world'); tree.create('a/b/file2', 'hello world'); tree.create('a/c/file3', 'hello world'); - callRule(move(''), observableOf(tree), context) - .toPromise() - .then(result => { - expect(result.exists('a/b/file1')).toBe(true); - expect(result.exists('a/b/file2')).toBe(true); - expect(result.exists('a/c/file3')).toBe(true); - }) - .then(done, done.fail); + const result = await lastValueFrom(callRule(move(''), observableOf(tree), context)); + expect(result.exists('a/b/file1')).toBe(true); + expect(result.exists('a/b/file2')).toBe(true); + expect(result.exists('a/c/file3')).toBe(true); }); }); diff --git a/packages/angular_devkit/schematics/src/rules/random.ts b/packages/angular_devkit/schematics/src/rules/random.ts index a2575f57a5b0..68b0bde82286 100644 --- a/packages/angular_devkit/schematics/src/rules/random.ts +++ b/packages/angular_devkit/schematics/src/rules/random.ts @@ -1,41 +1,44 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { Source } from '../engine/interface'; import { HostTree } from '../tree/host-tree'; - function generateStringOfLength(l: number) { - return new Array(l).fill(0).map(_x => { - return 'abcdefghijklmnopqrstuvwxyz'[Math.floor(Math.random() * 26)]; - }).join(''); + return new Array(l) + .fill(0) + .map((_x) => { + return 'abcdefghijklmnopqrstuvwxyz'[Math.floor(Math.random() * 26)]; + }) + .join(''); } - function random(from: number, to: number) { return Math.floor(Math.random() * (to - from)) + from; } - export interface RandomOptions { root?: string; multi?: boolean | number; multiFiles?: boolean | number; } - -export default function(options: RandomOptions): Source { +export default function (options: RandomOptions): Source { return () => { - const root = ('root' in options) ? options.root : '/'; + const root = 'root' in options ? options.root : '/'; const map = new HostTree(); - const nbFiles = ('multiFiles' in options) - ? (typeof options.multiFiles == 'number' ? options.multiFiles : random(2, 12)) - : 1; + const nbFiles = + 'multiFiles' in options + ? typeof options.multiFiles == 'number' + ? options.multiFiles + : random(2, 12) + : 1; for (let i = 0; i < nbFiles; i++) { const path = 'a/b/c/d/e/f'.slice(Math.random() * 10); diff --git a/packages/angular_devkit/schematics/src/rules/rename.ts b/packages/angular_devkit/schematics/src/rules/rename.ts deleted file mode 100644 index 2405d5302273..000000000000 --- a/packages/angular_devkit/schematics/src/rules/rename.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @license - * 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 - */ -import { normalize } from '@angular-devkit/core'; -import { Rule } from '../engine/interface'; -import { FilePredicate } from '../tree/interface'; -import { forEach } from './base'; - - -export function rename(match: FilePredicate, to: FilePredicate): Rule { - return forEach(entry => { - if (match(entry.path, entry)) { - return { - content: entry.content, - path: normalize(to(entry.path, entry)), - }; - } else { - return entry; - } - }); -} diff --git a/packages/angular_devkit/schematics/src/rules/rename_spec.ts b/packages/angular_devkit/schematics/src/rules/rename_spec.ts deleted file mode 100644 index c9fdda3cdd4a..000000000000 --- a/packages/angular_devkit/schematics/src/rules/rename_spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @license - * 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 - */ -// tslint:disable:no-non-null-assertion -import { of as observableOf } from 'rxjs'; -import { SchematicContext } from '../engine/interface'; -import { HostTree } from '../tree/host-tree'; -import { callRule } from './call'; -import { rename } from './rename'; - - -const context: SchematicContext = null !; - - -describe('rename', () => { - it('works', done => { - const tree = new HostTree(); - tree.create('a/b/file1', 'hello world'); - tree.create('a/b/file2', 'hello world'); - tree.create('a/c/file3', 'hello world'); - - let i = 0; - - // Rename all files that contain 'b' to 'hello'. - callRule(rename(x => !!x.match(/b/), () => 'hello' + (i++)), observableOf(tree), context) - .toPromise() - .then(result => { - expect(result.exists('a/b/file1')).toBe(false); - expect(result.exists('a/b/file2')).toBe(false); - expect(result.exists('hello0')).toBe(true); - expect(result.exists('hello1')).toBe(true); - expect(result.exists('a/c/file3')).toBe(true); - }) - .then(done, done.fail); - }); - - it('works (2)', done => { - const tree = new HostTree(); - tree.create('a/b/file1', 'hello world'); - tree.create('a/b/file2', 'hello world'); - tree.create('a/c/file3', 'hello world'); - - let i = 0; - - // Rename all files that contain 'b' to 'hello'. - callRule(rename(x => !!x.match(/b/), x => x + (i++)), observableOf(tree), context) - .toPromise() - .then(result => { - expect(result.exists('a/b/file1')).toBe(false); - expect(result.exists('a/b/file2')).toBe(false); - expect(result.exists('a/b/file10')).toBe(true); - expect(result.exists('a/b/file21')).toBe(true); - expect(result.exists('a/c/file3')).toBe(true); - }) - .then(done, done.fail); - }); -}); diff --git a/packages/angular_devkit/schematics/src/rules/schematic.ts b/packages/angular_devkit/schematics/src/rules/schematic.ts index 6885ad9bacb6..ffa313246539 100644 --- a/packages/angular_devkit/schematics/src/rules/schematic.ts +++ b/packages/angular_devkit/schematics/src/rules/schematic.ts @@ -1,17 +1,16 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { of as observableOf } from 'rxjs'; -import { last, map } from 'rxjs/operators'; + +import { last, map, of as observableOf } from 'rxjs'; import { ExecutionOptions, Rule, SchematicContext } from '../engine/interface'; import { MergeStrategy, Tree } from '../tree/interface'; import { branch } from '../tree/static'; - /** * Run a schematic from a separate collection. * @@ -26,14 +25,23 @@ export function externalSchematic( executionOptions?: Partial, ): Rule { return (input: Tree, context: SchematicContext) => { - const collection = context.engine.createCollection(collectionName); + const collection = context.engine.createCollection( + collectionName, + context.schematic.collection, + ); const schematic = collection.createSchematic(schematicName); - return schematic.call(options, observableOf(branch(input)), context, executionOptions); + return schematic.call(options, observableOf(branch(input)), context, executionOptions).pipe( + last(), + map((x) => { + input.merge(x, MergeStrategy.AllowOverwriteConflict); + + return input; + }), + ); }; } - /** * Run a schematic from the same collection. * @@ -51,7 +59,7 @@ export function schematic( return schematic.call(options, observableOf(branch(input)), context, executionOptions).pipe( last(), - map(x => { + map((x) => { // We allow overwrite conflict here because they're the only merge conflict we particularly // don't want to deal with; the input tree might have an OVERWRITE which the sub input.merge(x, MergeStrategy.AllowOverwriteConflict); diff --git a/packages/angular_devkit/schematics/src/rules/template.ts b/packages/angular_devkit/schematics/src/rules/template.ts index 3e5d34e9241b..c01249cc235d 100644 --- a/packages/angular_devkit/schematics/src/rules/template.ts +++ b/packages/angular_devkit/schematics/src/rules/template.ts @@ -1,43 +1,43 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 { BaseException, normalize, template as templateImpl } from '@angular-devkit/core'; +import { EOL } from 'node:os'; import { FileOperator, Rule } from '../engine/interface'; import { FileEntry } from '../tree/interface'; import { chain, composeFileOperators, forEach, when } from './base'; -import { rename } from './rename'; -import { isBinary } from './utils/is-binary'; - - -export const TEMPLATE_FILENAME_RE = /\.template$/; +export const TEMPLATE_FILENAME_RE: RegExp = /\.template$/; export class OptionIsNotDefinedException extends BaseException { - constructor(name: string) { super(`Option "${name}" is not defined.`); } + constructor(name: string) { + super(`Option "${name}" is not defined.`); + } } - export class UnknownPipeException extends BaseException { - constructor(name: string) { super(`Pipe "${name}" is not defined.`); } + constructor(name: string) { + super(`Pipe "${name}" is not defined.`); + } } - export class InvalidPipeException extends BaseException { - constructor(name: string) { super(`Pipe "${name}" is invalid.`); } + constructor(name: string) { + super(`Pipe "${name}" is invalid.`); + } } - export type PathTemplateValue = boolean | string | number | undefined; export type PathTemplatePipeFunction = (x: string) => PathTemplateValue; export type PathTemplateData = { - [key: string]: PathTemplateValue | PathTemplateData | PathTemplatePipeFunction, + [key: string]: PathTemplateValue | PathTemplateData | PathTemplatePipeFunction; }; - export interface PathTemplateOptions { // Interpolation start and end strings. interpolationStart: string; @@ -48,27 +48,38 @@ export interface PathTemplateOptions { pipeSeparator?: string; } +const decoder = new TextDecoder('utf-8', { fatal: true }); export function applyContentTemplate(options: T): FileOperator { return (entry: FileEntry) => { - const {path, content} = entry; - if (isBinary(content)) { - return entry; - } + const { path, content } = entry; + + try { + const decodedContent = decoder.decode(content).replace(/\r?\n/g, EOL); + + return { + path, + content: Buffer.from(templateImpl(decodedContent, {})(options)), + }; + } catch (e) { + // The second part should not be needed. But Jest does not support instanceof correctly. + // See: https://github.com/jestjs/jest/issues/2549 + if ( + e instanceof TypeError || + (e as NodeJS.ErrnoException).code === 'ERR_ENCODING_INVALID_ENCODED_DATA' + ) { + return entry; + } - return { - path: path, - content: Buffer.from(templateImpl(content.toString('utf-8'), {})(options)), - }; + throw e; + } }; } - export function contentTemplate(options: T): Rule { return forEach(applyContentTemplate(options)); } - export function applyPathTemplate( data: T, options: PathTemplateOptions = { @@ -122,12 +133,13 @@ export function applyPathTemplate( if (!(pipe in data)) { throw new UnknownPipeException(pipe); } - if (typeof data[pipe] != 'function') { + const pipeFn = data[pipe]; + if (typeof pipeFn != 'function') { throw new InvalidPipeException(pipe); } // Coerce to string. - return '' + (data[pipe] as PathTemplatePipeFunction)(acc); + return '' + pipeFn(acc); }, '' + replacement); } @@ -142,24 +154,27 @@ export function applyPathTemplate( }; } - export function pathTemplate(options: T): Rule { return forEach(applyPathTemplate(options)); } - /** * Remove every `.template` suffix from file names. */ export function renameTemplateFiles(): Rule { - return rename( - path => !!path.match(TEMPLATE_FILENAME_RE), - path => path.replace(TEMPLATE_FILENAME_RE, ''), - ); + return forEach((entry) => { + if (entry.path.match(TEMPLATE_FILENAME_RE)) { + return { + content: entry.content, + path: normalize(entry.path.replace(TEMPLATE_FILENAME_RE, '')), + }; + } else { + return entry; + } + }); } - -export function template(options: T): Rule { +export function template(options: T): Rule { return chain([ contentTemplate(options), // Force cast to PathTemplateData. We need the type for the actual pathTemplate() call, @@ -169,16 +184,15 @@ export function template(options: T): Rule { ]); } - -export function applyTemplates(options: T): Rule { +export function applyTemplates(options: T): Rule { return forEach( when( - path => path.endsWith('.template'), + (path) => path.endsWith('.template'), composeFileOperators([ applyContentTemplate(options), // See above for this weird cast. applyPathTemplate(options as {} as PathTemplateData), - entry => { + (entry) => { return { content: entry.content, path: entry.path.replace(TEMPLATE_FILENAME_RE, ''), diff --git a/packages/angular_devkit/schematics/src/rules/template_spec.ts b/packages/angular_devkit/schematics/src/rules/template_spec.ts index 56e99f9a90dd..1833d8e7f37c 100644 --- a/packages/angular_devkit/schematics/src/rules/template_spec.ts +++ b/packages/angular_devkit/schematics/src/rules/template_spec.ts @@ -1,14 +1,15 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -// tslint:disable:no-implicit-dependencies + +/* eslint-disable import/no-extraneous-dependencies */ import { normalize } from '@angular-devkit/core'; -import { UnitTestTree } from '@angular-devkit/schematics/testing'; import { of as observableOf } from 'rxjs'; +import { UnitTestTree } from '../../testing'; import { SchematicContext } from '../engine/interface'; import { HostTree } from '../tree/host-tree'; import { FileEntry, MergeStrategy } from '../tree/interface'; @@ -22,7 +23,6 @@ import { applyTemplates, } from './template'; - function _entry(path?: string, content?: string): FileEntry { if (!path) { path = 'a/b/c'; @@ -37,7 +37,6 @@ function _entry(path?: string, content?: string): FileEntry { }; } - describe('applyPathTemplate', () => { function _applyPathTemplate(path: string, options: {}): string | null { const newEntry = applyPathTemplate(options)(_entry(path)); @@ -61,26 +60,29 @@ describe('applyPathTemplate', () => { }); it('works with complex _________...', () => { - expect(_applyPathTemplate( - '/_____' + 'a' + '___a__' + '_/' + '__a___/__b___', - { _a: 0, a: 1, b: 2, _: '.' }, - )).toBe('/.a0_/1_/2_'); - - expect(_applyPathTemplate( - '_____' + '_____' + '_____' + '___', - { _: '.' }, - )).toBe('...___'); + expect( + _applyPathTemplate('/_____' + 'a' + '___a__' + '_/' + '__a___/__b___', { + _a: 0, + a: 1, + b: 2, + _: '.', + }), + ).toBe('/.a0_/1_/2_'); + + expect(_applyPathTemplate('_____' + '_____' + '_____' + '___', { _: '.' })).toBe('...___'); }); it('works with functions', () => { let arg = ''; - expect(_applyPathTemplate('/a__c__b', { - c: (x: string) => { - arg = x; - - return 'hello'; - }, - })).toBe('/ahellob'); + expect( + _applyPathTemplate('/a__c__b', { + c: (x: string) => { + arg = x; + + return 'hello'; + }, + }), + ).toBe('/ahellob'); expect(arg).toBe('/a__c__b'); }); @@ -88,17 +90,21 @@ describe('applyPathTemplate', () => { let called = ''; let called2 = ''; - expect(_applyPathTemplate('/a__c@d__b', { - c: 1, - d: (x: string) => (called = x, 2), - })).toBe('/a2b'); + expect( + _applyPathTemplate('/a__c@d__b', { + c: 1, + d: (x: string) => ((called = x), 2), + }), + ).toBe('/a2b'); expect(called).toBe('1'); - expect(_applyPathTemplate('/a__c@d@e__b', { - c: 10, - d: (x: string) => (called = x, 20), - e: (x: string) => (called2 = x, 30), - })).toBe('/a30b'); + expect( + _applyPathTemplate('/a__c@d@e__b', { + c: 10, + d: (x: string) => ((called = x), 20), + e: (x: string) => ((called2 = x), 30), + }), + ).toBe('/a30b'); expect(called).toBe('10'); expect(called2).toBe('20'); }); @@ -109,12 +115,12 @@ describe('applyPathTemplate', () => { it('errors out on undefined or invalid pipes', () => { expect(() => _applyPathTemplate('/a__b@d__c', { b: 1 })).toThrow(new UnknownPipeException('d')); - expect(() => _applyPathTemplate('/a__b@d__c', { b: 1, d: 1 })) - .toThrow(new InvalidPipeException('d')); + expect(() => _applyPathTemplate('/a__b@d__c', { b: 1, d: 1 })).toThrow( + new InvalidPipeException('d'), + ); }); }); - describe('contentTemplate', () => { function _applyContentTemplate(content: string, options: {}) { const newEntry = applyContentTemplate(options)(_entry('', content)); @@ -130,38 +136,45 @@ describe('contentTemplate', () => { }); it('works with if', () => { - expect(_applyContentTemplate('a<% if (a) { %>b<% } %>c', { - value: 123, - a: true, - })).toBe('abc'); - expect(_applyContentTemplate('a<% if (a) { %>b<% } %>c', { - value: 123, - a: false, - })).toBe('ac'); + expect( + _applyContentTemplate('a<% if (a) { %>b<% } %>c', { + value: 123, + a: true, + }), + ).toBe('abc'); + expect( + _applyContentTemplate('a<% if (a) { %>b<% } %>c', { + value: 123, + a: false, + }), + ).toBe('ac'); }); it('works with for', () => { - expect(_applyContentTemplate('a<% for (let i = 0; i < value; i++) { %>1<% } %>b', { - value: 5, - })).toBe('a11111b'); + expect( + _applyContentTemplate('a<% for (let i = 0; i < value; i++) { %>1<% } %>b', { + value: 5, + }), + ).toBe('a11111b'); }); it('escapes HTML', () => { - expect(_applyContentTemplate('a<%- html %>b', { - html: '', + '', + '', + ].join(''), + ); + } else { + await expectFileToMatch( + 'dist/test-project/browser/index.html', + [ + '', + '', + '', + '', + '', + ].join(''), + ); + } +} diff --git a/tests/e2e/tests/basic/serve.ts b/tests/e2e/tests/basic/serve.ts new file mode 100644 index 000000000000..eac4823a3126 --- /dev/null +++ b/tests/e2e/tests/basic/serve.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { killAllProcesses } from '../../utils/process'; +import { ngServe } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + // Serve works without HMR + const noHmrPort = await ngServe('--no-hmr'); + await verifyResponse(noHmrPort); + await killAllProcesses(); + + // Serve works with HMR + const hmrPort = await ngServe('--hmr'); + await verifyResponse(hmrPort); + + await executeBrowserTest({ baseUrl: `http://localhost:${hmrPort}/` }); +} + +async function verifyResponse(port: number): Promise { + const indexResponse = await fetch(`http://localhost:${port}/`); + assert.match(await indexResponse.text(), /<\/app-root>/); + + const assetResponse = await fetch(`http://localhost:${port}/favicon.ico`); + assert(assetResponse.ok, 'Expected favicon asset to be available.'); +} diff --git a/tests/e2e/tests/basic/styles-array.ts b/tests/e2e/tests/basic/styles-array.ts new file mode 100644 index 000000000000..cc4d6d56f506 --- /dev/null +++ b/tests/e2e/tests/basic/styles-array.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { expectFileToMatch, writeMultipleFiles } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + await writeMultipleFiles({ + 'src/string-style.css': '.string-style { color: red }', + 'src/input-style.css': '.input-style { color: red }', + 'src/lazy-style.css': '.lazy-style { color: red }', + 'src/pre-rename-style.css': '.pre-rename-style { color: red }', + 'src/pre-rename-lazy-style.css': '.pre-rename-lazy-style { color: red }', + }); + + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.styles = [ + { input: 'src/string-style.css' }, + { input: 'src/input-style.css' }, + { input: 'src/lazy-style.css', inject: false }, + { input: 'src/pre-rename-style.css', bundleName: 'renamed-style' }, + { + input: 'src/pre-rename-lazy-style.css', + bundleName: 'renamed-lazy-style', + inject: false, + }, + ]; + }); + + const { stdout } = await ng('build', '--configuration=development'); + + await expectFileToMatch('dist/test-project/browser/styles.css', '.string-style'); + await expectFileToMatch('dist/test-project/browser/styles.css', '.input-style'); + await expectFileToMatch('dist/test-project/browser/lazy-style.css', '.lazy-style'); + await expectFileToMatch('dist/test-project/browser/renamed-style.css', '.pre-rename-style'); + await expectFileToMatch( + 'dist/test-project/browser/renamed-lazy-style.css', + '.pre-rename-lazy-style', + ); + await expectFileToMatch( + 'dist/test-project/browser/index.html', + '', + ); + + // Non injected styles should be listed under lazy chunk files + assert.match(stdout, /Lazy chunk files[\s\S]+renamed-lazy-style\.css/m); +} diff --git a/tests/e2e/tests/basic/test.ts b/tests/e2e/tests/basic/test.ts new file mode 100644 index 000000000000..50580581d442 --- /dev/null +++ b/tests/e2e/tests/basic/test.ts @@ -0,0 +1,59 @@ +import { ng } from '../../utils/process'; +import { writeMultipleFiles } from '../../utils/fs'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + // make sure both --watch=false work + await ng('test', '--watch=false'); + + // Works with custom config + await writeMultipleFiles({ + './karma.conf.bis.js': ` + // Karma configuration file, see link for more information + // https://karma-runner.github.io/1.0/config/configuration-file.html + module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage'), + require('@angular-devkit/build-angular/plugins/karma') + ], + client: { + clearContext: false // leave Jasmine Spec Runner output visible in browser + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['ChromeHeadlessNoSandbox'], + customLaunchers: { + ChromeHeadlessNoSandbox: { + base: 'ChromeHeadless', + flags: [ + '--no-sandbox', + '--headless', + '--disable-gpu', + '--disable-dev-shm-usage', + ], + } + }, + singleRun: false, + restartOnFileChange: true + }); + }; + `, + }); + + const isWebpack = !getGlobalVariable('argv')['esbuild']; + + if (isWebpack) { + await ng('test', '--watch=false', '--karma-config=karma.conf.bis.js'); + } else { + await ng('test', '--watch=false', '--runner-config=karma.conf.bis.js'); + } +} diff --git a/tests/e2e/tests/build/app-shell/app-shell-ngmodule.ts b/tests/e2e/tests/build/app-shell/app-shell-ngmodule.ts new file mode 100644 index 000000000000..b1b6cfab499d --- /dev/null +++ b/tests/e2e/tests/build/app-shell/app-shell-ngmodule.ts @@ -0,0 +1,35 @@ +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileToMatch } from '../../../utils/fs'; +import { installPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; + +const snapshots = require('../../../ng-snapshot/package.json'); + +export default async function () { + await ng('generate', 'app', 'test-project-two', '--routing', '--no-standalone', '--skip-install'); + await ng('generate', 'app-shell', '--project', 'test-project-two'); + + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + if (isSnapshotBuild) { + const packagesToInstall: string[] = []; + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + // Iterate over all of the packages to update them to the snapshot version. + for (const [name, version] of Object.entries( + snapshots.dependencies as { [p: string]: string }, + )) { + if (name in dependencies && dependencies[name] !== version) { + packagesToInstall.push(version); + } + } + }); + + for (const pkg of packagesToInstall) { + await installPackage(pkg); + } + } + + await ng('build', 'test-project-two'); + await expectFileToMatch('dist/test-project-two/browser/index.html', 'app-shell works!'); +} diff --git a/tests/e2e/tests/build/app-shell/app-shell-with-schematic.ts b/tests/e2e/tests/build/app-shell/app-shell-with-schematic.ts new file mode 100644 index 000000000000..65f6fdb23c48 --- /dev/null +++ b/tests/e2e/tests/build/app-shell/app-shell-with-schematic.ts @@ -0,0 +1,34 @@ +import { getGlobalVariable } from '../../../utils/env'; +import { appendToFile, expectFileToMatch } from '../../../utils/fs'; +import { installPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; + +const snapshots = require('../../../ng-snapshot/package.json'); + +export default async function () { + await appendToFile('src/app/app.html', ''); + await ng('generate', 'app-shell', '--project', 'test-project'); + + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + if (isSnapshotBuild) { + const packagesToInstall: string[] = []; + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + // Iterate over all of the packages to update them to the snapshot version. + for (const [name, version] of Object.entries( + snapshots.dependencies as { [p: string]: string }, + )) { + if (name in dependencies && dependencies[name] !== version) { + packagesToInstall.push(version); + } + } + }); + + for (const pkg of packagesToInstall) { + await installPackage(pkg); + } + } + await ng('build'); + await expectFileToMatch('dist/test-project/browser/index.html', 'app-shell works!'); +} diff --git a/tests/e2e/tests/build/app-shell/app-shell-with-service-worker.ts b/tests/e2e/tests/build/app-shell/app-shell-with-service-worker.ts new file mode 100644 index 000000000000..0841754ad1b2 --- /dev/null +++ b/tests/e2e/tests/build/app-shell/app-shell-with-service-worker.ts @@ -0,0 +1,65 @@ +import { setTimeout } from 'node:timers/promises'; +import { getGlobalVariable } from '../../../utils/env'; +import { appendToFile, expectFileToMatch } from '../../../utils/fs'; +import { installPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; +import { executeBrowserTest } from '../../../utils/puppeteer'; + +const snapshots = require('../../../ng-snapshot/package.json'); + +export default async function () { + await appendToFile('src/app/app.html', ''); + await ng('generate', 'service-worker', '--project', 'test-project'); + await ng('generate', 'app-shell', '--project', 'test-project'); + + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + if (isSnapshotBuild) { + const packagesToInstall: string[] = []; + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + // Iterate over all of the packages to update them to the snapshot version. + for (const [name, version] of Object.entries( + snapshots.dependencies as { [p: string]: string }, + )) { + if (name in dependencies && dependencies[name] !== version) { + packagesToInstall.push(version); + } + } + }); + + for (const pkg of packagesToInstall) { + await installPackage(pkg); + } + } + + await ng('build'); + await expectFileToMatch('dist/test-project/browser/index.html', /app-shell works!/); + + await executeBrowserTest({ + configuration: 'production', + checkFn: async (page) => { + // Wait for service worker to load. + await setTimeout(2000); + + const baseUrl = page.url(); + await page.goto(new URL('/ngsw/state', baseUrl).href); + + // Should have updated, and be in normal state. + const preText = await page.$eval('pre', (el) => el.textContent); + if (preText?.includes('Last update check: never')) { + throw new Error(`Expected service worker to have checked for updates, but got: ${preText}`); + } + + // TODO: Investigate why the last condition fails with vite-based setup. + // Temporarily disabled to support protractor migration. + if (getGlobalVariable('argv')['esbuild']) { + return; + } + + if (!preText?.includes('Driver state: NORMAL')) { + throw new Error(`Expected service worker driver state to be NORMAL, but got: ${preText}`); + } + }, + }); +} diff --git a/tests/e2e/tests/build/assets.ts b/tests/e2e/tests/build/assets.ts new file mode 100644 index 000000000000..0d3c718b270e --- /dev/null +++ b/tests/e2e/tests/build/assets.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { writeFile, stat, mkdir, symlink, utimes } from 'node:fs/promises'; +import { expectFileToExist, expectFileToMatch } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + // Update the atime and mtime of the original file. + // Note: Node.js has different time precision, which may cause mtime-based tests to fail. + // Ensure both values are rounded to the same precision for consistency. + // Example: + // Original: '1742973507738.0234' + // Node.js CP: '1742973507737.999' + const { atime, mtime } = await stat('public/favicon.ico'); + await utimes('public/favicon.ico', atime, mtime); + + await writeFile('public/.file', ''); + await writeFile('public/test.abc', 'hello world'); + + await ng('build', '--configuration=development'); + await expectFileToExist('dist/test-project/browser/favicon.ico'); + await expectFileToExist('dist/test-project/browser/.file'); + await expectFileToMatch('dist/test-project/browser/test.abc', 'hello world'); + await expectToFail(() => expectFileToExist('dist/test-project/browser/.gitkeep')); + + // Timestamp preservation only supported with application build system on Node.js v22+ + if (getGlobalVariable('argv')['esbuild']) { + const [originalStats, outputStats] = await Promise.all([ + stat('public/favicon.ico'), + stat('dist/test-project/browser/favicon.ico'), + ]); + + assert.equal( + originalStats.mtimeMs, + outputStats.mtimeMs, + 'Asset file modified timestamp should be preserved.', + ); + } + + // Ensure `followSymlinks` option follows symlinks + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect['build'].options.assets = [ + { glob: '**/*', input: 'public', followSymlinks: true }, + ]; + }); + + await mkdir('dirToSymlink/subdir1', { recursive: true }); + await mkdir('dirToSymlink/subdir2/subsubdir1', { recursive: true }); + await symlink(process.cwd() + '/dirToSymlink', 'public/symlinkDir'); + + await Promise.all([ + writeFile('dirToSymlink/a.txt', ''), + writeFile('dirToSymlink/subdir1/b.txt', ''), + writeFile('dirToSymlink/subdir2/c.txt', ''), + writeFile('dirToSymlink/subdir2/subsubdir1/d.txt', ''), + ]); + + await ng('build', '--configuration=development'); + + await expectFileToExist('dist/test-project/browser/symlinkDir/a.txt'); + await expectFileToExist('dist/test-project/browser/symlinkDir/subdir1/b.txt'); + await expectFileToExist('dist/test-project/browser/symlinkDir/subdir2/c.txt'); + await expectFileToExist('dist/test-project/browser/symlinkDir/subdir2/subsubdir1/d.txt'); +} diff --git a/tests/e2e/tests/build/auto-csp.ts b/tests/e2e/tests/build/auto-csp.ts new file mode 100644 index 000000000000..aa4ed0e53f18 --- /dev/null +++ b/tests/e2e/tests/build/auto-csp.ts @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import { setTimeout } from 'node:timers/promises'; +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToMatch, writeFile, writeMultipleFiles } from '../../utils/fs'; +import { findFreePort } from '../../utils/network'; +import { execAndWaitForOutputToMatch, ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +const CSP_META_TAG = / { + const build = json['projects']['test-project']['architect']['build']; + build.options = { + ...build.options, + security: { autoCsp: true }, + }; + }); + + await writeMultipleFiles({ + 'serve.js': ` + const express = require('express'); + const path = require('path'); + + const app = express(); + const PORT = process.env.PORT || 3000; + + app.use(express.static(path.join(__dirname, 'dist/test-project/browser'))); + + app.listen(PORT, () => { + console.log('Node Express server listening on ' + PORT); + }); + `, + 'public/script1.js': ` + const externalScriptCreated = 1337; + console.warn('First External Script: ' + inlineScriptBodyCreated); + `, + 'public/script2.js': `console.warn('Second External Script: ' + externalScriptCreated);`, + 'src/index.html': ` + + + + + + + + + + + + + + + + `, + }); + + async function spawnServer(): Promise { + const port = await findFreePort(); + + await execAndWaitForOutputToMatch('node', ['serve.js'], /Node Express server listening on/, { + ...process.env, + 'PORT': String(port), + }); + + return port; + } + + await ng('build'); + + // Make sure the output files have auto-CSP as a result of `ng build` + await expectFileToMatch('dist/test-project/browser/index.html', CSP_META_TAG); + + // Make sure if contains the critical CSS inlining CSP code. + await expectFileToMatch('dist/test-project/browser/index.html', 'data-beasties-media'); + + // Make sure that our e2e tests run to confirm that our angular project runs. + const port = await spawnServer(); + await executeBrowserTest({ + baseUrl: `http://localhost:${port}/`, + checkFn: async (page) => { + const warnMessages: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'warn') { + warnMessages.push(msg.text()); + } + }); + + // Reload to ensure we capture messages from the start if needed, + // although executeBrowserTest already navigated. + await page.reload(); + + // Wait for the expected number of warnings + let retries = 50; + while (warnMessages.length < 4 && retries > 0) { + await setTimeout(100); + retries--; + } + + assert.strictEqual( + warnMessages.length, + 4, + `Expected 4 console warnings, but got ${warnMessages.length}:\n${warnMessages.join('\n')}`, + ); + + const expectedMessages = [ + 'Inline Script Head', + 'Inline Script Body: 1339', + 'First External Script: 1338', + 'Second External Script: 1337', + ]; + + for (let i = 0; i < expectedMessages.length; i++) { + if (!warnMessages[i].includes(expectedMessages[i])) { + assert.fail( + `Expected warning ${i} to include '${expectedMessages[i]}', but got '${warnMessages[i]}'`, + ); + } + } + }, + }); +} diff --git a/tests/e2e/tests/build/bundle-budgets.ts b/tests/e2e/tests/build/bundle-budgets.ts new file mode 100644 index 000000000000..1401d8d3c6e3 --- /dev/null +++ b/tests/e2e/tests/build/bundle-budgets.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ +import assert from 'node:assert/strict'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + // Error + await updateJsonFile('angular.json', (json) => { + json.projects['test-project'].architect.build.configurations.production.budgets = [ + { type: 'all', maximumError: '100b' }, + ]; + }); + + const { message: errorMessage } = await expectToFail(() => ng('build')); + assert.match(errorMessage, /Error.+budget/i, 'Budget error: all, max error.'); + + // Warning + await updateJsonFile('angular.json', (json) => { + json.projects['test-project'].architect.build.configurations.production.budgets = [ + { type: 'all', minimumWarning: '100mb' }, + ]; + }); + + const { stderr } = await ng('build'); + assert.match(stderr, /Warning.+budget/i, 'Budget warning: all, min warning'); + + // Pass + await updateJsonFile('angular.json', (json) => { + json.projects['test-project'].architect.build.configurations.production.budgets = [ + { type: 'allScript', maximumError: '100mb' }, + ]; + }); + + const { stderr: stderr2 } = await ng('build'); + assert.doesNotMatch(stderr2, /(Warning|Error)/i, 'BIG max for all, should not error'); +} diff --git a/tests/e2e/tests/build/chunk-optimizer-animations.ts b/tests/e2e/tests/build/chunk-optimizer-animations.ts new file mode 100644 index 000000000000..2a4e46adc676 --- /dev/null +++ b/tests/e2e/tests/build/chunk-optimizer-animations.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import { getGlobalVariable } from '../../utils/env'; +import { readFile, writeFile } from '../../utils/fs'; +import { installPackage } from '../../utils/packages'; +import { execWithEnv } from '../../utils/process'; +import { readNgVersion } from '../../utils/version'; + +export default async function () { + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + let animationsSpecifier: string; + if (isSnapshotBuild) { + const snapshots = require('../../ng-snapshot/package.json'); + animationsSpecifier = snapshots.dependencies['@angular/animations']; + } else { + const coreVersion = readNgVersion(); + animationsSpecifier = `@angular/animations@${coreVersion}`; + } + + // Install @angular/animations package + await installPackage(animationsSpecifier); + + // Configure app.config.ts with provideAnimationsAsync + const originalConfig = await readFile('src/app/app.config.ts'); + await writeFile( + 'src/app/app.config.ts', + ` + import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; + ${originalConfig.replace(/providers:\s*\[/, 'providers: [provideAnimationsAsync(),')} + `, + ); + + const updatedConfig = await readFile('src/app/app.config.ts'); + assert.ok( + updatedConfig.includes('provideAnimationsAsync()'), + 'Expected src/app/app.config.ts to include provideAnimationsAsync().', + ); + + // Build with chunk optimization + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: '1', + }); + const optimizedFiles = await readdir('dist/test-project/browser'); + const optimizedJsFiles = optimizedFiles.filter((f) => f.endsWith('.js')); + + // Read the optimized main.js file + const mainCode = await readFile('dist/test-project/browser/main.js'); + + // Check that optimized chunks still contain more than 1 javascript file + assert.ok( + optimizedJsFiles.length > 1, + `Expected more than one chunk, but found ${optimizedJsFiles.length}.`, + ); + + // Check that one of the lazy loaded chunks contains the animations package code + let foundAnimationsChunk = false; + for (const file of optimizedJsFiles) { + if (file === 'main.js') { + continue; + } + const code = await readFile(`dist/test-project/browser/${file}`); + if (code.includes('AnimationEngine')) { + foundAnimationsChunk = true; + break; + } + } + + assert.ok( + foundAnimationsChunk, + 'Expected to find AnimationEngine in one of the optimized lazy chunks.', + ); + + // The animations engine should not be bundled in main.js + assert.ok( + !mainCode.includes('AnimationEngine'), + 'Expected main.js not to contain AnimationEngine from @angular/animations/browser.', + ); +} diff --git a/tests/e2e/tests/build/chunk-optimizer-env.ts b/tests/e2e/tests/build/chunk-optimizer-env.ts new file mode 100644 index 000000000000..e5221811664e --- /dev/null +++ b/tests/e2e/tests/build/chunk-optimizer-env.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import { replaceInFile } from '../../utils/fs'; +import { execWithEnv, ng } from '../../utils/process'; +import { installPackage, uninstallPackage } from '../../utils/packages'; + +export default async function () { + // Case 1: Force on with true/1 with 1 lazy chunk + await ng('generate', 'component', 'lazy-a'); + await replaceInFile( + 'src/app/app.routes.ts', + 'routes: Routes = [];', + `routes: Routes = [ + { + path: 'lazy-a', + loadComponent: () => import('./lazy-a/lazy-a').then(m => m.LazyA), + }, + ];`, + ); + + // Build with forced optimization + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'true', + }); + + // Build with forced off + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'false', + }); + + // We just verify it runs without error. + // With 1 chunk it might not be able to optimize further, so counts might be equal. + + // Case 2: Force off with false/0 with 3 lazy chunks + await ng('generate', 'component', 'lazy-b'); + await ng('generate', 'component', 'lazy-c'); + await replaceInFile( + 'src/app/app.routes.ts', + `path: 'lazy-a', + loadComponent: () => import('./lazy-a/lazy-a').then(m => m.LazyA), + },`, + `path: 'lazy-a', + loadComponent: () => import('./lazy-a/lazy-a').then(m => m.LazyA), + }, + { + path: 'lazy-b', + loadComponent: () => import('./lazy-b/lazy-b').then(m => m.LazyB), + }, + { + path: 'lazy-c', + loadComponent: () => import('./lazy-c/lazy-c').then(m => m.LazyC), + },`, + ); + + // Build with forced off + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'false', + }); + const files3Unopt = await readdir('dist/test-project/browser'); + const jsFiles3Unopt = files3Unopt.filter((f) => f.endsWith('.js')); + + // Build with default (should optimize because 3 chunks) + await ng('build', '--output-hashing=none'); + const files3Default = await readdir('dist/test-project/browser'); + const jsFiles3Default = files3Default.filter((f) => f.endsWith('.js')); + + assert.ok( + jsFiles3Default.length < jsFiles3Unopt.length, + `Expected default build (3 chunks) to be optimized compared to forced off. Default: ${jsFiles3Default.length}, Forced Off: ${jsFiles3Unopt.length}`, + ); + + // Case 3: Custom threshold + // Set threshold to 4 with 3 chunks -> should NOT optimize! + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: '4', + }); + const files3Thresh4 = await readdir('dist/test-project/browser'); + const jsFiles3Thresh4 = files3Thresh4.filter((f) => f.endsWith('.js')); + + assert.ok( + jsFiles3Thresh4.length >= jsFiles3Unopt.length, + `Expected build with threshold 4 and 3 chunks to NOT be optimized. Thresh 4: ${jsFiles3Thresh4.length}, Unoptimized: ${jsFiles3Unopt.length}`, + ); + + // Case 4: Opt back into Rollup (requires manual install since it's an optional peer dependency) + await installPackage('rollup@4.62.0'); + try { + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_CHUNKS_ROLLDOWN: 'false', + NG_BUILD_OPTIMIZE_CHUNKS: 'true', + }); + const filesRollup = await readdir('dist/test-project/browser'); + const jsFilesRollup = filesRollup.filter((f) => f.endsWith('.js')); + + assert.ok(jsFilesRollup.length > 0, 'Expected Rollup build to produce output files.'); + } finally { + // Clean up + await uninstallPackage('rollup'); + } +} diff --git a/tests/e2e/tests/build/chunk-optimizer-heuristic.ts b/tests/e2e/tests/build/chunk-optimizer-heuristic.ts new file mode 100644 index 000000000000..f8db4220699a --- /dev/null +++ b/tests/e2e/tests/build/chunk-optimizer-heuristic.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import { replaceInFile } from '../../utils/fs'; +import { execWithEnv, ng } from '../../utils/process'; + +export default async function () { + // Case 1: 2 lazy chunks (below threshold of 3) -> should NOT optimize by default + await ng('generate', 'component', 'lazy-a'); + await ng('generate', 'component', 'lazy-b'); + await replaceInFile( + 'src/app/app.routes.ts', + 'routes: Routes = [];', + `routes: Routes = [ + { + path: 'lazy-a', + loadComponent: () => import('./lazy-a/lazy-a').then(m => m.LazyA), + }, + { + path: 'lazy-b', + loadComponent: () => import('./lazy-b/lazy-b').then(m => m.LazyB), + }, + ];`, + ); + + // Build without explicit flag (should use default threshold of 3) + await ng('build', '--output-hashing=none'); + const files2 = await readdir('dist/test-project/browser'); + const jsFiles2 = files2.filter((f) => f.endsWith('.js')); + + // Build with forced optimization to see if it COULD reduce chunks + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'true', + }); + const files2Opt = await readdir('dist/test-project/browser'); + const jsFiles2Opt = files2Opt.filter((f) => f.endsWith('.js')); + + // If forced optimization reduces chunks, then default should have MORE chunks (since it didn't run). + // If forced optimization doesn't reduce chunks, they will be equal. + // So we assert that default is NOT fewer than forced. + assert.ok( + jsFiles2.length >= jsFiles2Opt.length, + `Expected default build with 2 lazy chunks to NOT be optimized. Default: ${jsFiles2.length}, Forced: ${jsFiles2Opt.length}`, + ); + + // Case 2: 3 lazy chunks (at threshold of 3) -> should optimize by default + await ng('generate', 'component', 'lazy-c'); + await replaceInFile( + 'src/app/app.routes.ts', + `path: 'lazy-b', + loadComponent: () => import('./lazy-b/lazy-b').then(m => m.LazyB), + },`, + `path: 'lazy-b', + loadComponent: () => import('./lazy-b/lazy-b').then(m => m.LazyB), + }, + { + path: 'lazy-c', + loadComponent: () => import('./lazy-c/lazy-c').then(m => m.LazyC), + },`, + ); + + // Build without explicit flag (should use default threshold of 3) + await ng('build', '--output-hashing=none'); + const files3 = await readdir('dist/test-project/browser'); + const jsFiles3 = files3.filter((f) => f.endsWith('.js')); + + // Build with explicit disable + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'false', + }); + const files3Unopt = await readdir('dist/test-project/browser'); + const jsFiles3Unopt = files3Unopt.filter((f) => f.endsWith('.js')); + + // Expect default build to be optimized (fewer chunks than explicitly disabled) + assert.ok( + jsFiles3.length < jsFiles3Unopt.length, + `Expected default build with 3 lazy chunks to be optimized. Default: ${jsFiles3.length}, Unoptimized: ${jsFiles3Unopt.length}`, + ); +} diff --git a/tests/e2e/tests/build/chunk-optimizer-lazy.ts b/tests/e2e/tests/build/chunk-optimizer-lazy.ts new file mode 100644 index 000000000000..9b51c74d0898 --- /dev/null +++ b/tests/e2e/tests/build/chunk-optimizer-lazy.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import { replaceInFile } from '../../utils/fs'; +import { execWithEnv, ng } from '../../utils/process'; + +export default async function () { + // Add lazy routes. + await ng('generate', 'component', 'lazy-a'); + await ng('generate', 'component', 'lazy-b'); + await ng('generate', 'component', 'lazy-c'); + await replaceInFile( + 'src/app/app.routes.ts', + 'routes: Routes = [];', + `routes: Routes = [ + { + path: 'lazy-a', + loadComponent: () => import('./lazy-a/lazy-a').then(m => m.LazyA), + }, + { + path: 'lazy-b', + loadComponent: () => import('./lazy-b/lazy-b').then(m => m.LazyB), + }, + { + path: 'lazy-c', + loadComponent: () => import('./lazy-c/lazy-c').then(m => m.LazyC), + }, + ];`, + ); + + // Build without chunk optimization + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: 'false', + }); + const unoptimizedFiles = await readdir('dist/test-project/browser'); + const unoptimizedJsFiles = unoptimizedFiles.filter((f) => f.endsWith('.js')); + + // Build with chunk optimization + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: '1', + }); + const optimizedFiles = await readdir('dist/test-project/browser'); + const optimizedJsFiles = optimizedFiles.filter((f) => f.endsWith('.js')); + + // Check that the number of chunks is reduced but not all combined + assert.ok( + optimizedJsFiles.length < unoptimizedJsFiles.length, + `Expected chunk count to be less than ${unoptimizedJsFiles.length}, but was ${optimizedJsFiles.length}.`, + ); + assert.ok( + optimizedJsFiles.length > 1, + `Expected more than one chunk, but found ${optimizedJsFiles.length}.`, + ); +} diff --git a/tests/e2e/tests/build/chunk-optimizer.ts b/tests/e2e/tests/build/chunk-optimizer.ts new file mode 100644 index 000000000000..fff428b47263 --- /dev/null +++ b/tests/e2e/tests/build/chunk-optimizer.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { execWithEnv } from '../../utils/process'; + +/** + * AOT builds with chunk optimizer should contain generated component definitions. + * This is currently testing that the generated code is propagating through the + * chunk optimization step. + */ +export default async function () { + await execWithEnv('ng', ['build', '--output-hashing=none'], { + ...process.env, + NG_BUILD_OPTIMIZE_CHUNKS: '1', + NG_BUILD_MANGLE: '0', + }); + + const content = await readFile('dist/test-project/browser/main.js', 'utf-8'); + assert.match(content, /(ɵɵ|\\u0275\\u0275)defineComponent/u); +} diff --git a/tests/e2e/tests/build/config-file-fallback.ts b/tests/e2e/tests/build/config-file-fallback.ts new file mode 100644 index 000000000000..53298b573e12 --- /dev/null +++ b/tests/e2e/tests/build/config-file-fallback.ts @@ -0,0 +1,9 @@ +import { ng } from '../../utils/process'; +import { moveFile } from '../../utils/fs'; + +export default function () { + return Promise.resolve() + .then(() => ng('build')) + .then(() => moveFile('angular.json', '.angular.json')) + .then(() => ng('build')); +} diff --git a/tests/e2e/tests/build/css-urls.ts b/tests/e2e/tests/build/css-urls.ts new file mode 100644 index 000000000000..839ecf58f567 --- /dev/null +++ b/tests/e2e/tests/build/css-urls.ts @@ -0,0 +1,225 @@ +import { ng } from '../../utils/process'; +import { + expectFileToMatch, + expectFileToExist, + expectFileMatchToExist, + writeMultipleFiles, +} from '../../utils/fs'; +import { copyProjectAsset } from '../../utils/assets'; +import { expectToFail } from '../../utils/utils'; +import { getGlobalVariable } from '../../utils/env'; +import { mkdir } from 'node:fs/promises'; + +const imgSvg = ` + + + +`; + +export default async function () { + const usingWebpack = !getGlobalVariable('argv')['esbuild']; + + const mediaPath = usingWebpack + ? './dist/test-project/browser' + : './dist/test-project/browser/media'; + + await mkdir('public/assets/', { recursive: true }); + + await Promise.resolve() + // Verify absolute/relative paths in global/component css. + .then(() => + writeMultipleFiles({ + 'src/styles.css': ` + h1 { background: url('/assets/global-img-absolute.svg'); } + h2 { background: url('./assets/global-img-relative.png'); } + `, + 'src/app/app.css': ` + h3 { background: url('/assets/component-img-absolute.svg'); } + h4 { background: url('../assets/component-img-relative.png'); } + `, + 'public/assets/global-img-absolute.svg': imgSvg, + 'public/assets/component-img-absolute.svg': imgSvg, + }), + ) + .then(() => copyProjectAsset('images/spectrum.png', './src/assets/global-img-relative.png')) + .then(() => copyProjectAsset('images/spectrum.png', './src/assets/component-img-relative.png')) + .then(() => ng('build', '--aot', '--configuration=development')) + // Check paths are correctly generated. + .then(() => + expectFileToMatch('dist/test-project/browser/styles.css', 'assets/global-img-absolute.svg'), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /url\((['"]?)\/assets\/global-img-absolute\.svg\1\)/, + ), + ) + .then(() => + expectFileToMatch('dist/test-project/browser/styles.css', /global-img-relative\.png/), + ) + .then(() => + expectFileToMatch('dist/test-project/browser/main.js', '/assets/component-img-absolute.svg'), + ) + .then(() => + expectFileToMatch('dist/test-project/browser/main.js', /component-img-relative\.png/), + ) + // Check files are correctly created. + .then(() => expectToFail(() => expectFileToExist(`${mediaPath}/global-img-absolute.svg`))) + .then(() => expectToFail(() => expectFileToExist(`${mediaPath}/component-img-absolute.svg`))) + .then(() => expectFileMatchToExist(mediaPath, /global-img-relative\.png/)) + .then(() => expectFileMatchToExist(mediaPath, /component-img-relative\.png/)); + + // Early exit before deploy url tests + if (!usingWebpack) { + return; + } + + // Check urls with deploy-url scheme are used as is. + return ( + Promise.resolve() + .then(() => + ng( + 'build', + '--base-href=/base/', + '--deploy-url=http://deploy.url/', + '--configuration=development', + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /url\(\'\/assets\/global-img-absolute\.svg\'\)/, + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /url\(\'\/assets\/component-img-absolute\.svg\'\)/, + ), + ) + // Check urls with base-href scheme are used as is (with deploy-url). + .then(() => + ng( + 'build', + '--base-href=http://base.url/', + '--deploy-url=deploy/', + '--configuration=development', + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /url\(\'\/assets\/global-img-absolute\.svg\'\)/, + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /url\(\'\/assets\/component-img-absolute\.svg\'\)/, + ), + ) + // Check urls with deploy-url and base-href scheme only use deploy-url. + .then(() => + ng( + 'build', + '--base-href=http://base.url/', + '--deploy-url=http://deploy.url/', + '--configuration=development', + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + /url\(\'\/assets\/global-img-absolute\.svg\'\)/, + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /url\(\'\/assets\/component-img-absolute\.svg\'\)/, + ), + ) + // Check with base-href and deploy-url flags. + .then(() => + ng( + 'build', + '--base-href=/base/', + '--deploy-url=deploy/', + '--aot', + '--configuration=development', + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + '/assets/global-img-absolute.svg', + ), + ) + .then(() => + expectFileToMatch('dist/test-project/browser/styles.css', /global-img-relative\.png/), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + '/assets/component-img-absolute.svg', + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /deploy\/component-img-relative\.png/, + ), + ) + // Check with identical base-href and deploy-url flags. + .then(() => + ng( + 'build', + '--base-href=/base/', + '--deploy-url=/base/', + '--aot', + '--configuration=development', + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + '/assets/global-img-absolute.svg', + ), + ) + .then(() => + expectFileToMatch('dist/test-project/browser/styles.css', /global-img-relative\.png/), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + '/assets/component-img-absolute.svg', + ), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + /\/base\/component-img-relative\.png/, + ), + ) + // Check with only base-href flag. + .then(() => ng('build', '--base-href=/base/', '--aot', '--configuration=development')) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/styles.css', + '/assets/global-img-absolute.svg', + ), + ) + .then(() => + expectFileToMatch('dist/test-project/browser/styles.css', /global-img-relative\.png/), + ) + .then(() => + expectFileToMatch( + 'dist/test-project/browser/main.js', + '/assets/component-img-absolute.svg', + ), + ) + .then(() => + expectFileToMatch('dist/test-project/browser/main.js', /component-img-relative\.png/), + ) + ); +} diff --git a/tests/e2e/tests/build/disk-cache-purge.ts b/tests/e2e/tests/build/disk-cache-purge.ts new file mode 100644 index 000000000000..52edd4845038 --- /dev/null +++ b/tests/e2e/tests/build/disk-cache-purge.ts @@ -0,0 +1,30 @@ +import { join } from 'node:path'; +import { createDir, expectFileNotToExist, expectFileToExist, writeFile } from '../../utils/fs'; +import { silentNg } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + const cachePath = '.angular/cache'; + const staleCachePath = join(cachePath, 'v1.0.0'); + + // No need to include all applications code to verify disk cache existence. + await writeFile('src/main.ts', 'console.log(1);'); + + // Enable cache for all environments + await updateJsonFile('angular.json', (config) => { + config.cli ??= {}; + config.cli.cache = { + environment: 'all', + enabled: true, + path: cachePath, + }; + }); + + // Create a dummy stale disk cache directory. + await createDir(staleCachePath); + await expectFileToExist(staleCachePath); + + await silentNg('build'); + await expectFileToExist(cachePath); + await expectFileNotToExist(staleCachePath); +} diff --git a/tests/e2e/tests/build/disk-cache.ts b/tests/e2e/tests/build/disk-cache.ts new file mode 100644 index 000000000000..1873905646ca --- /dev/null +++ b/tests/e2e/tests/build/disk-cache.ts @@ -0,0 +1,56 @@ +import { expectFileNotToExist, expectFileToExist, rimraf, writeFile } from '../../utils/fs'; +import { silentNg } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +const defaultCachePath = '.angular/cache'; +const overriddenCachePath = '.cache/angular-cli'; + +export default async function () { + const originalCIValue = process.env['CI']; + + // No need to include all applications code to verify disk cache existence. + await writeFile('src/main.ts', 'console.log(1);'); + + try { + // Should be enabled by default. + process.env['CI'] = '0'; + await configureAndRunTest(); + + // Should not write cache when it's disabled + await configureAndRunTest({ enabled: false }); + await expectFileNotToExist(defaultCachePath); + + // Should not write cache by default when in CI. + process.env['CI'] = '1'; + await configureAndRunTest(); + await expectFileNotToExist(defaultCachePath); + + // Should write cache when it's enabled and 'environment' is set to 'all' or 'ci'. + await configureAndRunTest({ environment: 'all' }); + await expectFileToExist(defaultCachePath); + + // Should write cache to custom path when configured. + await configureAndRunTest({ environment: 'ci', path: overriddenCachePath }); + await expectFileNotToExist(defaultCachePath); + await expectFileToExist(overriddenCachePath); + } finally { + process.env['CI'] = originalCIValue; + } +} + +async function configureAndRunTest(cacheOptions?: { + environment?: 'ci' | 'local' | 'all'; + enabled?: boolean; + path?: string; +}): Promise { + await Promise.all([ + rimraf(overriddenCachePath), + rimraf(defaultCachePath), + updateJsonFile('angular.json', (config) => { + config.cli ??= {}; + config.cli.cache = cacheOptions; + }), + ]); + + await silentNg('build'); +} diff --git a/tests/e2e/tests/build/extract-licenses.ts b/tests/e2e/tests/build/extract-licenses.ts new file mode 100644 index 000000000000..04ab08f5d49e --- /dev/null +++ b/tests/e2e/tests/build/extract-licenses.ts @@ -0,0 +1,29 @@ +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToExist, expectFileToMatch } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + const usingWebpack = !getGlobalVariable('argv')['esbuild']; + + // Licenses should be left intact if extraction is disabled + await ng('build', '--extract-licenses=false', '--output-hashing=none'); + + if (usingWebpack) { + await expectToFail(() => expectFileToExist('dist/test-project/browser/3rdpartylicenses.txt')); + } else { + // Application builder puts the licenses at the output path root + await expectToFail(() => expectFileToExist('dist/test-project/3rdpartylicenses.txt')); + } + await expectFileToMatch('dist/test-project/browser/main.js', '@license'); + + // Licenses should be removed if extraction is enabled + await ng('build', '--extract-licenses', '--output-hashing=none'); + + if (usingWebpack) { + await expectFileToExist('dist/test-project/browser/3rdpartylicenses.txt'); + } else { + await expectFileToExist('dist/test-project/3rdpartylicenses.txt'); + } + await expectToFail(() => expectFileToMatch('dist/test-project/browser/main.js', '@license')); +} diff --git a/tests/e2e/tests/build/incremental-watch.ts b/tests/e2e/tests/build/incremental-watch.ts new file mode 100644 index 000000000000..68f281c8bab5 --- /dev/null +++ b/tests/e2e/tests/build/incremental-watch.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import { setTimeout } from 'node:timers/promises'; +import { getGlobalVariable } from '../../utils/env'; +import { appendToFile, readFile, writeFile } from '../../utils/fs'; +import { execAndWaitForOutputToMatch, waitForAnyProcessOutputToMatch } from '../../utils/process'; + +const buildReadyRegEx = /Application bundle generation complete\./; + +async function getOutputChunks( + dir: string, + predicate: (chunks: string[]) => boolean, + timeout = 10_000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + const chunks = (await readdir(dir)).filter((file) => file.endsWith('.js')); + if (predicate(chunks)) { + return chunks; + } + } catch (err: any) { + if (err?.code !== 'ENOENT') { + throw err; + } + } + await setTimeout(50); + } + + const chunks = (await readdir(dir)).filter((file) => file.endsWith('.js')); + assert(predicate(chunks), `Condition not met for chunks in ${dir}: ${JSON.stringify(chunks)}`); + + return chunks; +} + +export default async function () { + const usingApplicationBuilder = getGlobalVariable('argv')['esbuild']; + assert( + usingApplicationBuilder, + 'Incremental watch E2E test should not be executed with Webpack.', + ); + + // Perform an initial build in watch mode + await execAndWaitForOutputToMatch( + 'ng', + ['build', '--watch', '--configuration=development'], + buildReadyRegEx, + ); + const initialOutputChunks = await getOutputChunks( + 'dist/test-project/browser', + (chunks) => chunks.length > 0, + ); + + const originalMain = await readFile('src/main.ts'); + + // Add a dynamic import to create an additional output chunk + await Promise.all([ + waitForAnyProcessOutputToMatch(buildReadyRegEx), + writeFile( + 'src/a.ts', + ` + export function sayHi() { + console.log('hi'); + } + `, + ), + appendToFile('src/main.ts', `\nimport('./a').then((m) => m.sayHi());`), + ]); + const intermediateOutputChunks = await getOutputChunks( + 'dist/test-project/browser', + (chunks) => chunks.length > initialOutputChunks.length, + ); + assert( + initialOutputChunks.length < intermediateOutputChunks.length, + 'Additional chunks should be present', + ); + + // Remove usage of dynamic import which should remove the additional output chunk + await Promise.all([ + waitForAnyProcessOutputToMatch(buildReadyRegEx), + writeFile('src/main.ts', originalMain), + ]); + const finalOutputChunks = await getOutputChunks( + 'dist/test-project/browser', + (chunks) => chunks.length === initialOutputChunks.length, + ); + assert.equal( + initialOutputChunks.length, + finalOutputChunks.length, + 'Final chunk count should be equal to initial chunk count.', + ); +} diff --git a/tests/e2e/tests/build/jit-ngmodule.ts b/tests/e2e/tests/build/jit-ngmodule.ts new file mode 100644 index 000000000000..aa6b3fda86bb --- /dev/null +++ b/tests/e2e/tests/build/jit-ngmodule.ts @@ -0,0 +1,48 @@ +import { getGlobalVariable } from '../../utils/env'; +import { ng } from '../../utils/process'; +import { updateJsonFile, useCIDefaults } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + await ng('generate', 'app', 'test-project-two', '--no-standalone', '--skip-install'); + await useCIDefaults('test-project-two'); + + // Make prod use JIT. + + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + // Setup webpack builder if esbuild is not requested on the commandline + await updateJsonFile('angular.json', (json) => { + const build = json['projects']['test-project-two']['architect']['build']; + if (useWebpackBuilder) { + build.builder = '@angular-devkit/build-angular:browser'; + build.options = { + ...build.options, + main: build.options.browser, + browser: undefined, + buildOptimizer: false, + outputPath: 'dist/test-project-two', + index: 'src/index.html', + }; + + build.configurations.development = { + ...build.configurations.development, + vendorChunk: true, + namedChunks: true, + }; + } + + // Remove bundle budgets due to the increased size from JIT + build.configurations.production = { + ...build.configurations.production, + budgets: undefined, + }; + + build.options.aot = false; + + const serve = json['projects']['test-project-two']['architect']['serve']; + serve.builder = '@angular-devkit/build-angular:dev-server'; + }); + // Test it works + await executeBrowserTest({ project: 'test-project-two', configuration: 'production' }); + await executeBrowserTest({ project: 'test-project-two', configuration: 'development' }); +} diff --git a/tests/e2e/tests/build/jit-prod.ts b/tests/e2e/tests/build/jit-prod.ts new file mode 100644 index 000000000000..b2dc9d0bdddc --- /dev/null +++ b/tests/e2e/tests/build/jit-prod.ts @@ -0,0 +1,22 @@ +import { getGlobalVariable } from '../../utils/env'; +import { updateJsonFile } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + // Make prod use JIT. + await updateJsonFile('angular.json', (configJson) => { + const appArchitect = configJson.projects['test-project'].architect; + appArchitect.build.configurations['production'].aot = false; + + // JIT applications have significantly larger sizes + appArchitect.build.configurations['production'].budgets = []; + + if (!getGlobalVariable('argv')['esbuild']) { + // The build optimizer option does not exist with the application build system + appArchitect.build.configurations['production'].buildOptimizer = false; + } + }); + + // Test it works + await executeBrowserTest({ configuration: 'production' }); +} diff --git a/tests/e2e/tests/build/lazy-load-syntax.ts b/tests/e2e/tests/build/lazy-load-syntax.ts new file mode 100644 index 000000000000..bc0a375673dc --- /dev/null +++ b/tests/e2e/tests/build/lazy-load-syntax.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { replaceInFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + // Add lazy route. + await ng('generate', 'component', 'lazy-comp'); + await replaceInFile( + 'src/app/app.routes.ts', + 'routes: Routes = [];', + `routes: Routes = [{ + path: 'lazy', + loadComponent: () => import('./lazy-comp/lazy-comp').then(c => c.LazyComp), + }];`, + ); + + // Convert the default config to use JIT and prod to just do AOT. + // This way we can use `ng e2e` to test JIT and `ng e2e --configuration=production` to test AOT. + await updateJsonFile('angular.json', (json) => { + const buildTarget = json['projects']['test-project']['architect']['build']; + buildTarget['options']['aot'] = true; + buildTarget['configurations']['development']['aot'] = false; + }); + + const checkFn = async (page: any) => { + await page.goto(page.url() + 'lazy'); + await page.waitForFunction( + () => + !!(globalThis as any).document + .querySelector('app-lazy-comp p') + ?.textContent?.includes('lazy-comp works!'), + { timeout: 10000 }, + ); + }; + + await executeBrowserTest({ checkFn }); + await executeBrowserTest({ configuration: 'production', checkFn }); +} diff --git a/tests/e2e/tests/build/library-with-demo-app.ts b/tests/e2e/tests/build/library-with-demo-app.ts new file mode 100644 index 000000000000..8e8d14ceb54f --- /dev/null +++ b/tests/e2e/tests/build/library-with-demo-app.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { appendToFile, createDir, writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + await ng('generate', 'library', 'mylib'); + await createLibraryEntryPoint('secondary'); + await createLibraryEntryPoint('another'); + + // Scenario #1 where we use wildcard path mappings for secondary entry-points. + await updateJsonFile('tsconfig.json', (json) => { + json.compilerOptions.paths = { 'mylib': ['./dist/mylib'], 'mylib/*': ['./dist/mylib/*'] }; + }); + + await appendToFile( + 'src/app/app.config.ts', + ` + import * as secondary from 'mylib/secondary'; + import * as another from 'mylib/another'; + + console.log({ + secondary, + another + }); + `, + ); + + await ng('build', 'mylib'); + await ng('build'); + + // Scenario #2 where we don't use wildcard path mappings. + await updateJsonFile('tsconfig.json', (json) => { + json.compilerOptions.paths = { + 'mylib': ['./dist/mylib'], + 'mylib/secondary': ['./dist/mylib/secondary'], + 'mylib/another': ['./dist/mylib/another'], + }; + }); + + await ng('build'); +} + +async function createLibraryEntryPoint(name: string): Promise { + await createDir(`projects/mylib/${name}`); + await writeFile(`projects/mylib/${name}/index.ts`, `export const foo = 'foo';`); + + await writeFile( + `projects/mylib/${name}/ng-package.json`, + JSON.stringify({ + lib: { + entryFile: 'index.ts', + }, + }), + ); +} diff --git a/tests/e2e/tests/build/library/lib-consumption-full-aot.ts b/tests/e2e/tests/build/library/lib-consumption-full-aot.ts new file mode 100644 index 000000000000..08d114f1de4a --- /dev/null +++ b/tests/e2e/tests/build/library/lib-consumption-full-aot.ts @@ -0,0 +1,14 @@ +import { ng } from '../../../utils/process'; +import { executeBrowserTest } from '../../../utils/puppeteer'; +import { browserCheck, libraryConsumptionSetup } from './setup'; + +export default async function () { + await libraryConsumptionSetup(); + + // Build library in full mode (development) + await ng('build', 'my-lib', '--configuration=development'); + + // Check that the e2e succeeds prod and non prod mode + await executeBrowserTest({ configuration: 'production', checkFn: browserCheck }); + await executeBrowserTest({ configuration: 'development', checkFn: browserCheck }); +} diff --git a/tests/e2e/tests/build/library/lib-consumption-full-jit.ts b/tests/e2e/tests/build/library/lib-consumption-full-jit.ts new file mode 100644 index 000000000000..906a920dba44 --- /dev/null +++ b/tests/e2e/tests/build/library/lib-consumption-full-jit.ts @@ -0,0 +1,34 @@ +import { updateJsonFile } from '../../../utils/project'; +import { expectFileToMatch } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { executeBrowserTest } from '../../../utils/puppeteer'; +import { browserCheck, libraryConsumptionSetup } from './setup'; +import { getGlobalVariable } from '../../../utils/env'; + +export default async function () { + await libraryConsumptionSetup(); + + // Build library in full mode (development) + await ng('build', 'my-lib', '--configuration=development'); + + // JIT linking + await updateJsonFile('angular.json', (config) => { + const build = config.projects['test-project'].architect.build; + build.options.aot = false; + build.configurations.production.budgets = undefined; + if (!getGlobalVariable('argv')['esbuild']) { + build.configurations.production.buildOptimizer = false; + } + }); + + // Ensure app works in prod and non prod mode + await executeBrowserTest({ configuration: 'production', checkFn: browserCheck }); + await executeBrowserTest({ configuration: 'development', checkFn: browserCheck }); + + // Validate that sourcemaps for the library exists. + await ng('build', '--configuration=development'); + await expectFileToMatch( + 'dist/test-project/browser/main.js.map', + 'projects/my-lib/src/lib/my-lib.ts', + ); +} diff --git a/tests/e2e/tests/build/library/lib-consumption-partial-aot.ts b/tests/e2e/tests/build/library/lib-consumption-partial-aot.ts new file mode 100644 index 000000000000..f906be54b0e6 --- /dev/null +++ b/tests/e2e/tests/build/library/lib-consumption-partial-aot.ts @@ -0,0 +1,14 @@ +import { ng } from '../../../utils/process'; +import { executeBrowserTest } from '../../../utils/puppeteer'; +import { browserCheck, libraryConsumptionSetup } from './setup'; + +export default async function () { + await libraryConsumptionSetup(); + + // Build library in partial mode (production) + await ng('build', 'my-lib', '--configuration=production'); + + // Check that the e2e succeeds prod and non prod mode + await executeBrowserTest({ configuration: 'production', checkFn: browserCheck }); + await executeBrowserTest({ configuration: 'development', checkFn: browserCheck }); +} diff --git a/tests/e2e/tests/build/library/lib-consumption-partial-jit.ts b/tests/e2e/tests/build/library/lib-consumption-partial-jit.ts new file mode 100644 index 000000000000..503c09e525e9 --- /dev/null +++ b/tests/e2e/tests/build/library/lib-consumption-partial-jit.ts @@ -0,0 +1,27 @@ +import { updateJsonFile } from '../../../utils/project'; +import { ng } from '../../../utils/process'; +import { executeBrowserTest } from '../../../utils/puppeteer'; +import { browserCheck, libraryConsumptionSetup } from './setup'; +import { getGlobalVariable } from '../../../utils/env'; + +export default async function () { + await libraryConsumptionSetup(); + + // Build library in partial mode (production) + await ng('build', 'my-lib', '--configuration=production'); + + // JIT linking + await updateJsonFile('angular.json', (config) => { + const build = config.projects['test-project'].architect.build; + build.options.aot = false; + build.configurations.production.budgets = undefined; + + if (!getGlobalVariable('argv')['esbuild']) { + build.configurations.production.buildOptimizer = false; + } + }); + + // Check that the e2e succeeds prod and non prod mode + await executeBrowserTest({ configuration: 'production', checkFn: browserCheck }); + await executeBrowserTest({ configuration: 'development', checkFn: browserCheck }); +} diff --git a/tests/e2e/tests/build/library/lib-consumption-sourcemaps.ts b/tests/e2e/tests/build/library/lib-consumption-sourcemaps.ts new file mode 100644 index 000000000000..c0b7a5e78a73 --- /dev/null +++ b/tests/e2e/tests/build/library/lib-consumption-sourcemaps.ts @@ -0,0 +1,17 @@ +import { expectFileToMatch } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { libraryConsumptionSetup } from './setup'; + +export default async function () { + await libraryConsumptionSetup(); + + // Build library in full mode (development) + await ng('build', 'my-lib', '--configuration=development'); + + // Validate that sourcemaps for the library exists. + await ng('build', '--configuration=development'); + await expectFileToMatch( + 'dist/test-project/browser/main.js.map', + 'projects/my-lib/src/lib/my-lib.ts', + ); +} diff --git a/tests/e2e/tests/build/library/lib-unused-decorated-class-treeshake.ts b/tests/e2e/tests/build/library/lib-unused-decorated-class-treeshake.ts new file mode 100644 index 000000000000..33b221a32efe --- /dev/null +++ b/tests/e2e/tests/build/library/lib-unused-decorated-class-treeshake.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert'; +import { appendToFile, expectFileToExist, expectFileToMatch, readFile } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { libraryConsumptionSetup } from './setup'; +import { updateJsonFile } from '../../../utils/project'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + await ng('cache', 'off'); + await libraryConsumptionSetup(); + + // Add an unused class as part of the public api. + await appendToFile( + 'projects/my-lib/src/lib/my-lib.ts', + ` + function something() { + return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + console.log("someDecorator"); + }; + } + + export class ExampleClass { + @something() + method() {} + } + `, + ); + + // build the lib + await ng('build', 'my-lib', '--configuration=production'); + const packageJson = JSON.parse(await readFile('dist/my-lib/package.json')); + assert.equal(packageJson.sideEffects, false); + + // build the app + await ng('build', 'test-project', '--configuration=production', '--output-hashing=none'); + // Output should not contain `ExampleClass` as the library is marked as side-effect free. + await expectFileToExist('dist/test-project/browser/main.js'); + await expectToFail(() => expectFileToMatch('dist/test-project/browser/main.js', 'someDecorator')); + + // Mark library as side-effectful. + await updateJsonFile('dist/my-lib/package.json', (packageJson) => { + packageJson.sideEffects = true; + }); + + // build the app + await ng('build', 'test-project', '--configuration=production', '--output-hashing=none'); + // Output should contain `ExampleClass` as the library is marked as side-effectful. + await expectFileToMatch('dist/test-project/browser/main.js', 'someDecorator'); +} diff --git a/tests/e2e/tests/build/library/setup.ts b/tests/e2e/tests/build/library/setup.ts new file mode 100644 index 000000000000..621b740cf4bc --- /dev/null +++ b/tests/e2e/tests/build/library/setup.ts @@ -0,0 +1,45 @@ +import type { Page } from 'puppeteer'; +import { writeMultipleFiles } from '../../../utils/fs'; +import { silentNg } from '../../../utils/process'; + +export async function libraryConsumptionSetup(): Promise { + await silentNg('generate', 'library', 'my-lib'); + + // Force an external template + await writeMultipleFiles({ + 'projects/my-lib/src/lib/my-lib.html': `

my-lib works!

`, + 'projects/my-lib/src/lib/my-lib.ts': `import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-my-lib', + templateUrl: './my-lib.html', + }) + export class MyLibComponent {}`, + './src/app/app.ts': ` + import { Component } from '@angular/core'; + import { MyLibComponent } from 'my-lib'; + + @Component({ + selector: 'app-root', + template: '', + imports: [MyLibComponent], + }) + export class App { + title = 'test-project'; + + constructor() { + } + } + `, + }); +} + +export async function browserCheck(page: Page): Promise { + await page.waitForFunction( + () => + !!(globalThis as any).document + .querySelector('lib-my-lib p') + ?.textContent?.includes('my-lib works!'), + { timeout: 10000 }, + ); +} diff --git a/tests/e2e/tests/build/material.ts b/tests/e2e/tests/build/material.ts new file mode 100644 index 000000000000..bc4862f735fc --- /dev/null +++ b/tests/e2e/tests/build/material.ts @@ -0,0 +1,89 @@ +import { appendFile } from 'node:fs/promises'; +import { getGlobalVariable } from '../../utils/env'; +import { readFile, replaceInFile } from '../../utils/fs'; +import { + getActivePackageManager, + installPackage, + installWorkspacePackages, +} from '../../utils/packages'; +import { ng } from '../../utils/process'; +import { isPrereleaseCli, updateJsonFile } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +const snapshots = require('../../ng-snapshot/package.json'); + +export default async function () { + const isPrerelease = await isPrereleaseCli(); + let tag = isPrerelease ? '@next' : ''; + if (getActivePackageManager() === 'npm') { + await appendFile('.npmrc', '\nlegacy-peer-deps=true'); + } + + await ng('add', `@angular/material${tag}`, '--skip-confirmation'); + + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + if (isSnapshotBuild) { + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + // Angular material adds dependencies on other Angular packages + // Iterate over all of the packages to update them to the snapshot version. + for (const [name, version] of Object.entries(snapshots.dependencies)) { + if (name in dependencies) { + dependencies[name] = version; + } + } + + dependencies['@angular/material-moment-adapter'] = + snapshots.dependencies['@angular/material-moment-adapter']; + }); + await installWorkspacePackages(); + } else { + if (!tag) { + const installedMaterialVersion = JSON.parse(await readFile('package.json'))['dependencies'][ + '@angular/material' + ]; + tag = `@${installedMaterialVersion}`; + } + await installPackage(`@angular/material-moment-adapter${tag}`); + } + + await installPackage('moment'); + + await ng('build'); + + // Ensure moment adapter works (uses unique importing mechanism for moment) + // Issue: https://github.com/angular/angular-cli/issues/17320 + await replaceInFile( + 'src/app/app.config.ts', + `from '@angular/core';`, + ` + from '@angular/core'; + import { + MomentDateAdapter, + MAT_MOMENT_DATE_FORMATS + } from '@angular/material-moment-adapter'; + import { + DateAdapter, + MAT_DATE_LOCALE, + MAT_DATE_FORMATS + } from '@angular/material/core'; + `, + ); + + await replaceInFile( + 'src/app/app.config.ts', + `provideRouter(routes)`, + `provideRouter(routes), + { + provide: DateAdapter, + useClass: MomentDateAdapter, + deps: [MAT_DATE_LOCALE] + }, + { + provide: MAT_DATE_FORMATS, + useValue: MAT_MOMENT_DATE_FORMATS + }`, + ); + + await executeBrowserTest({ configuration: 'production' }); +} diff --git a/tests/e2e/tests/build/multiple-configs.ts b/tests/e2e/tests/build/multiple-configs.ts new file mode 100644 index 000000000000..dbbeba4658e3 --- /dev/null +++ b/tests/e2e/tests/build/multiple-configs.ts @@ -0,0 +1,63 @@ +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToExist } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + // TODO: Restructure to support application builder option + // This only needs to be tested once since it is really testing the CLI itself and not the builders + if (getGlobalVariable('argv')['esbuild']) { + return; + } + + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + // These are the default options, that we'll overwrite in subsequent configs. + // sourceMap defaults to true + appArchitect['build'] = { + ...appArchitect['build'], + defaultConfiguration: undefined, + options: { + ...appArchitect['build'].options, + optimization: false, + sourceMap: true, + outputHashing: 'none', + vendorChunk: true, + styles: ['src/styles.css'], + scripts: [], + budgets: [], + }, + configurations: { + development: { + sourceMap: true, + }, + one: { + assets: [], + }, + two: { + sourceMap: false, + }, + }, + }; + + return workspaceJson; + }); + + // Test the base configuration. + await ng('build', '--configuration=development'); + await expectFileToExist('dist/test-project/browser/favicon.ico'); + await expectFileToExist('dist/test-project/browser/main.js.map'); + await expectFileToExist('dist/test-project/browser/vendor.js'); + await ng('build'); + await expectFileToExist('dist/test-project/browser/styles.css'); + // Use two configurations. + await ng('build', '--configuration=one,two', '--vendor-chunk=false'); + await expectToFail(() => expectFileToExist('dist/test-project/browser/favicon.ico')); + await expectToFail(() => expectFileToExist('dist/test-project/browser/main.js.map')); + // Use two configurations and two overrides, one of which overrides a config. + await ng('build', '--configuration=one,two', '--vendor-chunk=false', '--source-map=true'); + await expectToFail(() => expectFileToExist('dist/test-project/browser/favicon.ico')); + await expectFileToExist('dist/test-project/browser/main.js.map'); + await expectToFail(() => expectFileToExist('dist/test-project/browser/vendor.js')); +} diff --git a/tests/e2e/tests/build/output-dir.ts b/tests/e2e/tests/build/output-dir.ts new file mode 100644 index 000000000000..0a93ebae67b4 --- /dev/null +++ b/tests/e2e/tests/build/output-dir.ts @@ -0,0 +1,29 @@ +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToExist } from '../../utils/fs'; +import { expectGitToBeClean } from '../../utils/git'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +export default function () { + // TODO(architect): Delete this test. It is now in devkit/build-angular. + + const usingWebpack = !getGlobalVariable('argv')['esbuild']; + + return ng('build', '--output-path', 'build-output', '--configuration=development') + .then(() => expectFileToExist(`./build-output/${usingWebpack ? '' : 'browser/'}index.html`)) + .then(() => expectFileToExist(`./build-output/${usingWebpack ? '' : 'browser/'}main.js`)) + .then(() => expectToFail(expectGitToBeClean)) + .then(() => + updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect.build.options.outputPath = 'config-build-output'; + }), + ) + .then(() => ng('build', '--configuration=development')) + .then(() => + expectFileToExist(`./config-build-output/${usingWebpack ? '' : 'browser/'}index.html`), + ) + .then(() => expectFileToExist(`./config-build-output/${usingWebpack ? '' : 'browser/'}main.js`)) + .then(() => expectToFail(expectGitToBeClean)); +} diff --git a/tests/e2e/tests/build/poll.ts b/tests/e2e/tests/build/poll.ts new file mode 100644 index 000000000000..3b72b7c2d4e6 --- /dev/null +++ b/tests/e2e/tests/build/poll.ts @@ -0,0 +1,29 @@ +import { setTimeout } from 'node:timers/promises'; +import { getGlobalVariable } from '../../utils/env'; +import { appendToFile } from '../../utils/fs'; +import { waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { ngServe } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +const webpackGoodRegEx = getGlobalVariable('argv')['esbuild'] + ? /Application bundle generation complete\./ + : / Compiled successfully\./; + +export default async function () { + await ngServe('--poll=10000'); + + // Wait before editing a file. + // Editing too soon seems to trigger a rebuild and throw polling out of whack. + await setTimeout(3000); + await appendToFile('src/main.ts', 'console.log(1);'); + + // We have to wait poll time + rebuild build time for the regex match. + await waitForAnyProcessOutputToMatch(webpackGoodRegEx, 14000); + + // No rebuilds should occur for a while + await appendToFile('src/main.ts', 'console.log(1);'); + await expectToFail(() => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 7000)); + + // But a rebuild should happen roughly within the 10 second window. + await waitForAnyProcessOutputToMatch(webpackGoodRegEx, 7000); +} diff --git a/tests/e2e/tests/build/prerender/discover-routes-ngmodule.ts b/tests/e2e/tests/build/prerender/discover-routes-ngmodule.ts new file mode 100644 index 000000000000..9d4843e00f7d --- /dev/null +++ b/tests/e2e/tests/build/prerender/discover-routes-ngmodule.ts @@ -0,0 +1,150 @@ +import { join } from 'node:path'; +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileToMatch, writeFile } from '../../../utils/fs'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { updateJsonFile, useSha } from '../../../utils/project'; + +export default async function () { + const projectName = 'test-project-two'; + await ng('generate', 'application', projectName, '--no-standalone', '--skip-install'); + + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + // Setup webpack builder if esbuild is not requested on the commandline + await updateJsonFile('angular.json', (json) => { + const build = json['projects'][projectName]['architect']['build']; + build.builder = '@angular-devkit/build-angular:browser'; + build.options = { + ...build.options, + main: build.options.browser, + browser: undefined, + index: 'src/index.html', + }; + + build.configurations.development = { + ...build.configurations.development, + vendorChunk: true, + namedChunks: true, + buildOptimizer: false, + }; + }); + } + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng( + 'add', + '@angular/ssr', + '--project', + projectName, + '--skip-confirmation', + '--skip-install', + ); + + await useSha(); + await installWorkspacePackages(); + + // Add routes + await writeFile( + `projects/${projectName}/src/app/app-routing-module.ts`, + ` + import { NgModule } from '@angular/core'; + import { RouterModule, Routes } from '@angular/router'; + import { One} from './one/one'; + import { TwoChildOne } from './two-child-one/two-child-one'; + import { TwoChildTwo } from './two-child-two/two-child-two'; + + const routes: Routes = [ + { + path: '', + component: One, + }, + { + path: 'two', + children: [ + { + path: 'two-child-one', + component: TwoChildOne, + }, + { + path: 'two-child-two', + component: TwoChildTwo, + }, + ], + }, + ]; + + @NgModule({ + imports: [RouterModule.forRoot(routes)], + exports: [RouterModule], + }) + export class AppRoutingModule {} + `, + ); + + // Generate components for the above routes + const componentNames: string[] = ['one', 'two-child-one', 'two-child-two']; + + for (const componentName of componentNames) { + await ng('generate', 'component', componentName, '--project', projectName); + } + + // Generate lazy routes + const lazyModules: [route: string, moduleName: string][] = [ + ['lazy-one', 'app-module'], + ['lazy-one-child', 'lazy-one/lazy-one-module'], + ['lazy-two', 'app-module'], + ]; + + for (const [route, moduleName] of lazyModules) { + await ng( + 'generate', + 'module', + route, + '--route', + route, + '--module', + moduleName, + '--project', + projectName, + ); + } + + // Prerender pages + if (useWebpackBuilder) { + await ng('run', `${projectName}:prerender:production`); + await runExpects(); + + return; + } + + await ng('build', projectName, '--configuration=production'); + await runExpects(); + + // Test also JIT mode. + await ng('build', projectName, '--configuration=development', '--no-aot'); + + await runExpects(); + + async function runExpects(): Promise { + const expects: Record = { + 'index.html': 'one works!', + 'two/index.html': 'router-outlet', + 'two/two-child-one/index.html': 'two-child-one works!', + 'two/two-child-two/index.html': 'two-child-two works!', + 'lazy-one/index.html': 'lazy-one works!', + 'lazy-one/lazy-one-child/index.html': 'lazy-one-child works!', + 'lazy-two/index.html': 'lazy-two works!', + }; + + if (!useWebpackBuilder) { + expects['index.csr.html'] = ''; + } + + const distPath = 'dist/' + projectName + '/browser'; + for (const [filePath, fileMatch] of Object.entries(expects)) { + await expectFileToMatch(join(distPath, filePath), fileMatch); + } + } +} diff --git a/tests/e2e/tests/build/prerender/discover-routes-standalone.ts b/tests/e2e/tests/build/prerender/discover-routes-standalone.ts new file mode 100644 index 000000000000..71a3ba2fb15d --- /dev/null +++ b/tests/e2e/tests/build/prerender/discover-routes-standalone.ts @@ -0,0 +1,126 @@ +import { join } from 'node:path'; +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileToMatch, readFile, writeFile } from '../../../utils/fs'; +import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { useSha } from '../../../utils/project'; +import { deepStrictEqual } from 'node:assert'; + +export default async function () { + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + + await useSha(); + await installWorkspacePackages(); + + // Add routes + await writeFile( + 'src/app/app.routes.ts', + ` + import { Routes } from '@angular/router'; + import { One } from './one/one'; + import { TwoChildOne } from './two-child-one/two-child-one'; + import { TwoChildTwo } from './two-child-two/two-child-two'; + + export const routes: Routes = [ + { + path: '', + component: One, + }, + { + path: 'two', + children: [ + { + path: 'two-child-one', + component: TwoChildOne, + }, + { + path: 'two-child-two', + component: TwoChildTwo, + }, + ], + }, + { + path: 'lazy-one', + children: [ + { + path: '', + loadComponent: () => import('./lazy-one/lazy-one').then(c => c.LazyOne), + }, + { + path: 'lazy-one-child', + loadComponent: () => import('./lazy-one-child/lazy-one-child').then(c => c.LazyOneChild), + }, + ], + }, + { + path: 'lazy-two', + loadComponent: () => import('./lazy-two/lazy-two').then(c => c.LazyTwo), + }, + ]; + `, + ); + + // Generate components for the above routes + const componentNames: string[] = [ + 'one', + 'two-child-one', + 'two-child-two', + 'lazy-one', + 'lazy-one-child', + 'lazy-two', + ]; + + for (const componentName of componentNames) { + await ng('generate', 'component', componentName); + } + + // Prerender pages + if (useWebpackBuilder) { + await ng('run', 'test-project:prerender:production'); + await runExpects(); + + return; + } + + await ng('build', '--configuration=production', '--prerender'); + await runExpects(); + + // Test also JIT mode. + await ng('build', '--configuration=development', '--prerender', '--no-aot'); + await runExpects(); + + async function runExpects(): Promise { + const expects: Record = { + 'index.html': 'one works!', + 'two/index.html': 'router-outlet', + 'two/two-child-one/index.html': 'two-child-one works!', + 'two/two-child-two/index.html': 'two-child-two works!', + 'lazy-one/index.html': 'lazy-one works!', + 'lazy-one/lazy-one-child/index.html': 'lazy-one-child works!', + 'lazy-two/index.html': 'lazy-two works!', + }; + + for (const [filePath, fileMatch] of Object.entries(expects)) { + await expectFileToMatch(join('dist/test-project/browser', filePath), fileMatch); + } + + if (!useWebpackBuilder) { + // prerendered-routes.json file is only generated when using esbuild. + const generatedRoutesStats = await readFile('dist/test-project/prerendered-routes.json'); + deepStrictEqual(JSON.parse(generatedRoutesStats), { + routes: { + '/': {}, + '/lazy-one': {}, + '/lazy-one/lazy-one-child': {}, + '/lazy-two': {}, + '/two': {}, + '/two/two-child-one': {}, + '/two/two-child-two': {}, + }, + }); + } + } +} diff --git a/tests/e2e/tests/build/prerender/error-component-render.ts b/tests/e2e/tests/build/prerender/error-component-render.ts new file mode 100644 index 000000000000..07c048c71c39 --- /dev/null +++ b/tests/e2e/tests/build/prerender/error-component-render.ts @@ -0,0 +1,72 @@ +import { existsSync } from 'node:fs'; +import assert, { match } from 'node:assert'; +import { getGlobalVariable } from '../../../utils/env'; +import { expectFileNotToExist, readFile, rimraf, writeMultipleFiles } from '../../../utils/fs'; +import { installWorkspacePackages } from '../../../utils/packages'; +import { ng } from '../../../utils/process'; +import { useSha } from '../../../utils/project'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + return; + } + + // Forcibly remove in case another test doesn't clean itself up. + await rimraf('node_modules/@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation'); + await useSha(); + await installWorkspacePackages(); + + await writeMultipleFiles({ + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-home', + standalone: true, + template: '

home works!

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

second works!

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

{{ data | json }}

+

{{ dataWithSpace | json }}

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

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

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

${lang}

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

${lang}

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

${lang}

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

${lang}

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

${lang}

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

${lang}

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

${lang}

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

${lang}

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

${translation.date}

`, `

${lang}

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

{{ assetsData | json }}

+

{{ apiData | json }}

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

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

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

{{ assetsData | json }}

+

{{ apiData | json }}

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

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

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

${lang}

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

${lang}

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

Hello, {{ user.name }}

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

{{ data | json }}

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

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

Hello world

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

', '

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

Hello world

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

Hello world

'); + // Actually use the generated component to ensure it is present in the application output + await writeFile( + 'src/app/app.ts', + ` + import { Component } from '@angular/core'; + import { I18nTest } from './i18n-test/i18n-test'; + + @Component({ + selector: 'app-root', + imports: [I18nTest], + template: '' + }) + export class App {} + `, + ); + + // Ensure localize package is not present initially + await uninstallPackage('@angular/localize'); + + // Should fail if `@angular/localize` is missing + const { message: message1 } = await expectToFail(() => ng('extract-i18n')); + if (!message1.includes(`i18n extraction requires the '@angular/localize' package.`)) { + throw new Error('Expected localize package error message when missing'); + } + + // Install correct version + let localizeVersion = '@angular/localize@' + readNgVersion(); + if (getGlobalVariable('argv')['ng-snapshots']) { + // The snapshots job won't work correctly because 'ng add' doesn't support github URLs + // localizeVersion = require('../../ng-snapshot/package.json').dependencies['@angular/localize']; + return; + } + await ng('add', localizeVersion, '--skip-confirmation'); + + // Should not show any warnings when extracting + const { stderr: message5 } = await ng('extract-i18n'); + if (message5.includes('WARNING')) { + throw new Error('Expected no warnings to be shown. STDERR:\n' + message5); + } + + await expectFileToMatch('messages.xlf', 'Hello world'); + + // Template messages should also be extracted when the build target disables AOT + await updateJsonFile('angular.json', (workspaceJson) => { + workspaceJson.projects['test-project'].architect.build.options.aot = false; + }); + await deleteFile('messages.xlf'); + await ng('extract-i18n'); + await expectFileToMatch('messages.xlf', 'Hello world'); + + await uninstallPackage('@angular/localize'); +} diff --git a/tests/e2e/tests/i18n/ivy-localize-app-shell-service-worker.ts b/tests/e2e/tests/i18n/ivy-localize-app-shell-service-worker.ts new file mode 100644 index 000000000000..b1568be95939 --- /dev/null +++ b/tests/e2e/tests/i18n/ivy-localize-app-shell-service-worker.ts @@ -0,0 +1,85 @@ +import { getGlobalVariable } from '../../utils/env'; +import { appendToFile, createDir, expectFileToMatch, writeFile } from '../../utils/fs'; +import { installWorkspacePackages } from '../../utils/packages'; +import { ng, silentNg } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { readNgVersion } from '../../utils/version'; + +const snapshots = require('../../ng-snapshot/package.json'); + +export default async function () { + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + dependencies['@angular/localize'] = isSnapshotBuild + ? snapshots.dependencies['@angular/localize'] + : readNgVersion(); + }); + + await appendToFile('src/app/app.html', ''); + + // Add app-shell and service-worker + await silentNg('generate', 'app-shell'); + await silentNg('generate', 'service-worker'); + + if (isSnapshotBuild) { + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + dependencies['@angular/platform-server'] = snapshots.dependencies['@angular/platform-server']; + dependencies['@angular/service-worker'] = snapshots.dependencies['@angular/service-worker']; + dependencies['@angular/router'] = snapshots.dependencies['@angular/router']; + }); + } + + await installWorkspacePackages(); + + // Set configurations for each locale. + const langTranslations = [ + { lang: 'en-US', translation: 'Hello i18n!' }, + { lang: 'fr', translation: 'Bonjour i18n!' }, + ]; + + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + const appArchitect = appProject.architect; + const buildOptions = appArchitect['build'].options; + + // Enable localization for all locales + buildOptions.localize = true; + + // Add locale definitions to the project + const i18n: Record = (appProject.i18n = { locales: {} }); + for (const { lang } of langTranslations) { + if (lang == 'en-US') { + i18n.sourceLocale = lang; + } else { + i18n.locales[lang] = `src/locale/messages.${lang}.xlf`; + } + } + }); + + await createDir('src/locale'); + + for (const { lang } of langTranslations) { + // dummy translation file. + await writeFile( + `src/locale/messages.${lang}.xlf`, + ` + + + + `, + ); + } + + // Build each locale and verify the SW output. + await ng('build', '--output-hashing=none'); + + for (const { lang } of langTranslations) { + await Promise.all([ + expectFileToMatch(`dist/test-project/browser/${lang}/ngsw.json`, `/${lang}/main.js`), + expectFileToMatch(`dist/test-project/browser/${lang}/ngsw.json`, `/${lang}/index.html`), + ]); + } +} diff --git a/tests/e2e/tests/i18n/ivy-localize-app-shell.ts b/tests/e2e/tests/i18n/ivy-localize-app-shell.ts new file mode 100644 index 000000000000..cbabbae17140 --- /dev/null +++ b/tests/e2e/tests/i18n/ivy-localize-app-shell.ts @@ -0,0 +1,108 @@ +import { getGlobalVariable } from '../../utils/env'; +import { + appendToFile, + copyFile, + expectFileToMatch, + replaceInFile, + writeFile, +} from '../../utils/fs'; +import { installWorkspacePackages } from '../../utils/packages'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { readNgVersion } from '../../utils/version'; + +const snapshots = require('../../ng-snapshot/package.json'); + +export default async function () { + const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; + + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + dependencies['@angular/localize'] = isSnapshotBuild + ? snapshots.dependencies['@angular/localize'] + : readNgVersion(); + }); + + await appendToFile('src/app/app.html', ''); + await ng('generate', 'app-shell', '--project', 'test-project'); + + if (isSnapshotBuild) { + await updateJsonFile('package.json', (packageJson) => { + const dependencies = packageJson['dependencies']; + dependencies['@angular/platform-server'] = snapshots.dependencies['@angular/platform-server']; + dependencies['@angular/router'] = snapshots.dependencies['@angular/router']; + }); + } + + await installWorkspacePackages(); + + // Set configurations for each locale. + const langTranslations = [ + { lang: 'en-US', translation: 'Hello i18n!' }, + { lang: 'fr', translation: 'Bonjour i18n!' }, + ]; + + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + const appArchitect = appProject.architect || appProject.targets; + const buildOptions = appArchitect['build'].options; + + // Enable localization for all locales + buildOptions.localize = true; + + // Add locale definitions to the project + const i18n: Record = (appProject.i18n = { locales: {} }); + for (const { lang } of langTranslations) { + if (lang == 'en-US') { + i18n.sourceLocale = lang; + } else { + i18n.locales[lang] = `src/locale/messages.${lang}.xlf`; + } + } + }); + + await writeFile( + 'src/app/app-shell/app-shell.html', + '

Hello i18n!

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

Hello i18n!

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

Hello {{ title }}!

+

{{ locale }}

+

{{ jan | date : 'LLLL' }}

+

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

+ + `, + ); + + await createDir(dirname(translationFile)); + await writeFile( + translationFile, + ` + + + + + + Hello ! + + src/app/app.html + 2,3 + + An introduction header for this sample + + + Updated + + src/app/app.html + 5,6 + + + + {VAR_PLURAL, plural, =0 {just now} =1 {one minute ago} other { minutes ago}} + + src/app/app.html + 5,6 + + + + + `, + ); + + // Add a dynamic import to ensure syntax is supported + // ng serve support: https://github.com/angular/angular-cli/issues/16248 + await writeFile('src/app/dynamic.ts', `export const abc = 5;`); + await appendToFile( + 'src/app/app.ts', + ` + (async () => { await import('./dynamic'); })(); + `, + ); + + // Update angular.json to build, serve, and test each locale. + await updateJsonFile('angular.json', (workspaceJson) => { + const appProject = workspaceJson.projects['test-project']; + const appArchitect = workspaceJson.projects['test-project'].architect; + const buildConfigs = appArchitect['build'].configurations; + const serveConfigs = appArchitect['serve'].configurations; + + appArchitect['build'].defaultConfiguration = undefined; + + // Always error on missing translations. + appArchitect['build'].options.optimization = true; + appArchitect['build'].options.aot = true; + appArchitect['build'].options.i18nMissingTranslation = 'error'; + appArchitect['build'].options.sourceMap = true; + appArchitect['build'].options.outputHashing = 'none'; + + const useWebpackBuilder = !getGlobalVariable('argv')['esbuild']; + if (useWebpackBuilder) { + appArchitect['build'].options.buildOptimizer = true; + appArchitect['build'].options.vendorChunk = true; + } + + // Enable localization for all locales + appArchitect['build'].options.localize = true; + + // Add i18n config items (app, build, serve, e2e). + // tslint:disable-next-line: no-any + const i18n: Record = (appProject.i18n = { locales: {} }); + for (const { lang } of langTranslations) { + if (lang === sourceLocale) { + i18n.sourceLocale = lang; + } else { + i18n.locales[lang] = `src/locale/messages.${lang}.xlf`; + } + + buildConfigs[lang] = { localize: [lang] }; + serveConfigs[lang] = { buildTarget: `test-project:build:${lang}` }; + } + }); + + // Install the localize package if using ivy + let localizeVersion = '@angular/localize@' + readNgVersion(); + if (getGlobalVariable('argv')['ng-snapshots']) { + localizeVersion = require('../../ng-snapshot/package.json').dependencies['@angular/localize']; + } + + await installPackage(localizeVersion); + + // Make translations for each language. + for (const { lang, translationReplacements } of langTranslations) { + if (lang != sourceLocale) { + await copyFile(translationFile, `src/locale/messages.${lang}.xlf`); + for (const replacements of translationReplacements!) { + await replaceInFile( + `src/locale/messages.${lang}.xlf`, + new RegExp(replacements[0], 'g'), + replacements[1] as string, + ); + } + + for (const replacement of [[/source/g, 'target']]) { + await replaceInFile( + `src/locale/messages.${lang}.xlf`, + new RegExp(replacement[0], 'g'), + replacement[1] as string, + ); + } + } + } +} diff --git a/tests/e2e/tests/mcp/ai-tutor.ts b/tests/e2e/tests/mcp/ai-tutor.ts new file mode 100644 index 000000000000..ed4d51a20c14 --- /dev/null +++ b/tests/e2e/tests/mcp/ai-tutor.ts @@ -0,0 +1,43 @@ +import { exec, ProcessOutput, silentNpm } from '../../utils/process'; +import assert from 'node:assert/strict'; + +const MCP_INSPECTOR_PACKAGE_NAME = '@modelcontextprotocol/inspector-cli'; +const MCP_INSPECTOR_PACKAGE_VERSION = '0.16.2'; +const MCP_INSPECTOR_COMMAND_NAME = 'mcp-inspector-cli'; + +async function runInspector(...args: string[]): Promise { + const result = await exec( + MCP_INSPECTOR_COMMAND_NAME, + '--cli', + 'npx', + '--no', + '@angular/cli', + 'mcp', + ...args, + ); + + return result; +} + +export default async function () { + await silentNpm( + 'install', + '--ignore-scripts', + '-g', + `${MCP_INSPECTOR_PACKAGE_NAME}@${MCP_INSPECTOR_PACKAGE_VERSION}`, + ); + + try { + // Ensure `get_best_practices` returns the markdown content + const { stdout: stdoutInsideWorkspace } = await runInspector( + '--method', + 'tools/call', + '--tool-name', + 'ai_tutor', + ); + + assert.match(stdoutInsideWorkspace, /# `airules.md` - Modern Angular Tutor 🧑‍🏫/); + } finally { + await silentNpm('uninstall', '-g', MCP_INSPECTOR_PACKAGE_NAME); + } +} diff --git a/tests/e2e/tests/mcp/best-practices.ts b/tests/e2e/tests/mcp/best-practices.ts new file mode 100644 index 000000000000..ba83733446dc --- /dev/null +++ b/tests/e2e/tests/mcp/best-practices.ts @@ -0,0 +1,46 @@ +import { exec, ProcessOutput, silentNpm } from '../../utils/process'; +import assert from 'node:assert/strict'; + +const MCP_INSPECTOR_PACKAGE_NAME = '@modelcontextprotocol/inspector-cli'; +const MCP_INSPECTOR_PACKAGE_VERSION = '0.16.2'; +const MCP_INSPECTOR_COMMAND_NAME = 'mcp-inspector-cli'; + +async function runInspector(...args: string[]): Promise { + const result = await exec( + MCP_INSPECTOR_COMMAND_NAME, + '--cli', + 'npx', + '--no', + '@angular/cli', + 'mcp', + ...args, + ); + + return result; +} + +export default async function () { + await silentNpm( + 'install', + '--ignore-scripts', + '-g', + `${MCP_INSPECTOR_PACKAGE_NAME}@${MCP_INSPECTOR_PACKAGE_VERSION}`, + ); + + try { + // Ensure `get_best_practices` returns the markdown content + const { stdout: stdoutInsideWorkspace } = await runInspector( + '--method', + 'tools/call', + '--tool-name', + 'get_best_practices', + ); + + assert.match( + stdoutInsideWorkspace, + /You are an expert in TypeScript, Angular, and scalable web application development./, + ); + } finally { + await silentNpm('uninstall', '-g', MCP_INSPECTOR_PACKAGE_NAME); + } +} diff --git a/tests/e2e/tests/mcp/projects-sourceroot-resolution.ts b/tests/e2e/tests/mcp/projects-sourceroot-resolution.ts new file mode 100644 index 000000000000..01ebb8c1ca05 --- /dev/null +++ b/tests/e2e/tests/mcp/projects-sourceroot-resolution.ts @@ -0,0 +1,47 @@ +import { exec, ProcessOutput, silentNpm } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import assert from 'node:assert/strict'; + +const MCP_INSPECTOR_PACKAGE_NAME = '@modelcontextprotocol/inspector-cli'; +const MCP_INSPECTOR_PACKAGE_VERSION = '0.16.2'; +const MCP_INSPECTOR_COMMAND_NAME = 'mcp-inspector-cli'; + +async function runInspector(...args: string[]): Promise { + return exec(MCP_INSPECTOR_COMMAND_NAME, '--cli', 'npx', '--no', '@angular/cli', 'mcp', ...args); +} + +export default async function () { + await silentNpm( + 'install', + '--ignore-scripts', + '-g', + `${MCP_INSPECTOR_PACKAGE_NAME}@${MCP_INSPECTOR_PACKAGE_VERSION}`, + ); + + try { + // 1. Add a sample project with a non-root path to angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + workspaceJson.projects ??= {}; + workspaceJson.projects['sample-lib'] = { + root: 'projects/sample-lib', + sourceRoot: 'projects/sample-lib/src', + projectType: 'library', + }; + }); + + // 2. Call list_projects + const { stdout } = await runInspector('--method', 'tools/call', '--tool-name', 'list_projects'); + + // 3. Verify output + assert.match(stdout, /"name": "sample-lib"/); + // Assert that sourceRoot is NOT duplicated + assert.match(stdout, /"sourceRoot": "projects\/sample-lib\/src"/); + assert.doesNotMatch(stdout, /"sourceRoot": "projects\/sample-lib\/projects\/sample-lib\/src"/); + } finally { + // 4. Cleanup angular.json + await updateJsonFile('angular.json', (workspaceJson) => { + delete workspaceJson.projects['sample-lib']; + }); + await silentNpm('uninstall', '-g', MCP_INSPECTOR_PACKAGE_NAME); + } +} diff --git a/tests/e2e/tests/mcp/registers-tools.ts b/tests/e2e/tests/mcp/registers-tools.ts new file mode 100644 index 000000000000..97e04459f440 --- /dev/null +++ b/tests/e2e/tests/mcp/registers-tools.ts @@ -0,0 +1,51 @@ +import { chdir } from 'node:process'; +import { exec, ProcessOutput, silentNpm } from '../../utils/process'; +import assert from 'node:assert/strict'; + +const MCP_INSPECTOR_PACKAGE_NAME = '@modelcontextprotocol/inspector-cli'; +const MCP_INSPECTOR_PACKAGE_VERSION = '0.16.2'; +const MCP_INSPECTOR_COMMAND_NAME = 'mcp-inspector-cli'; + +async function runInspector(...args: string[]): Promise { + const result = await exec( + MCP_INSPECTOR_COMMAND_NAME, + '--cli', + 'npx', + '--no', + '@angular/cli', + 'mcp', + ...args, + ); + + return result; +} + +export default async function () { + await silentNpm( + 'install', + '--ignore-scripts', + '-g', + `${MCP_INSPECTOR_PACKAGE_NAME}@${MCP_INSPECTOR_PACKAGE_VERSION}`, + ); + + const initialCwd = process.cwd(); + // Ensure 'list_projects' is registered when inside an Angular workspace + try { + const { stdout: stdoutInsideWorkspace } = await runInspector('--method', 'tools/list'); + + assert.match(stdoutInsideWorkspace, /"list_projects"/); + assert.match(stdoutInsideWorkspace, /"get_best_practices"/); + assert.match(stdoutInsideWorkspace, /"search_documentation"/); + + chdir('..'); + + const { stdout: stdoutOutsideWorkspace } = await runInspector('--method', 'tools/list'); + + assert.match(stdoutOutsideWorkspace, /"list_projects"/); + assert.match(stdoutOutsideWorkspace, /"get_best_practices"/); + assert.match(stdoutOutsideWorkspace, /"search_documentation"/); + } finally { + chdir(initialCwd); + await silentNpm('uninstall', '-g', MCP_INSPECTOR_PACKAGE_NAME); + } +} diff --git a/tests/e2e/tests/mcp/run-target.ts b/tests/e2e/tests/mcp/run-target.ts new file mode 100644 index 000000000000..16548fc4f3ca --- /dev/null +++ b/tests/e2e/tests/mcp/run-target.ts @@ -0,0 +1,80 @@ +import { getGlobalVariable } from '../../utils/env'; +import { exec, ProcessOutput, silentNpm } from '../../utils/process'; +import assert from 'node:assert/strict'; + +const MCP_INSPECTOR_PACKAGE_NAME = '@modelcontextprotocol/inspector-cli'; +const MCP_INSPECTOR_PACKAGE_VERSION = '0.16.2'; +const MCP_INSPECTOR_COMMAND_NAME = 'mcp-inspector-cli'; + +async function runInspector(...args: string[]): Promise { + const result = await exec( + MCP_INSPECTOR_COMMAND_NAME, + '--cli', + 'npx', + '--no', + '@angular/cli', + 'mcp', + ...args, + ); + + return result; +} + +export default async function () { + await silentNpm( + 'install', + '--ignore-scripts', + '-g', + `${MCP_INSPECTOR_PACKAGE_NAME}@${MCP_INSPECTOR_PACKAGE_VERSION}`, + ); + + try { + // 1. Ensure `run_target` is registered by default (now stable) + const { stdout: stdoutDefault } = await runInspector('--method', 'tools/list'); + assert.match(stdoutDefault, /"run_target"/); + + // 2. Ensure `run_target` is also registered when using the deprecated experimental-tool flag + const { stdout: stdoutEnabled } = await runInspector( + '-E', + 'run_target', + '--method', + 'tools/list', + ); + assert.match(stdoutEnabled, /"run_target"/); + + // 3. Call run_target with build target + const { stdout: stdoutCall } = await runInspector( + '--method', + 'tools/call', + '--tool-name', + 'run_target', + '--tool-arg', + 'target=build', + ); + assert.match(stdoutCall, /"status":\s*"success"/); + // Webpack-based browser builder does not print output paths to stdout logs. + // Only esbuild-based application builders output 'Output location: ...' and support outputPath extraction. + const esbuild = getGlobalVariable('argv')['esbuild']; + if (esbuild) { + assert.match(stdoutCall, /"outputPath":\s*"dist\/.+"/); + } else { + assert.doesNotMatch(stdoutCall, /"outputPath"/); + } + + // 4. Call run_target with test target (only for esbuild/Vite test runner, as webpack-based Karma fails on this bazel CI headless runner) + if (esbuild) { + const { stdout: stdoutTestCall } = await runInspector( + '--method', + 'tools/call', + '--tool-name', + 'run_target', + '--tool-arg', + 'target=test', + ); + assert.match(stdoutTestCall, /"status":\s*"success"/); + } + } finally { + // 5. Clean up global installation + await silentNpm('uninstall', '-g', MCP_INSPECTOR_PACKAGE_NAME); + } +} diff --git a/tests/e2e/tests/misc/ask-missing-builder.ts b/tests/e2e/tests/misc/ask-missing-builder.ts new file mode 100644 index 000000000000..ce4270cdbb47 --- /dev/null +++ b/tests/e2e/tests/misc/ask-missing-builder.ts @@ -0,0 +1,24 @@ +import { execAndWaitForOutputToMatch, killAllProcesses } from '../../utils/process'; + +export default async function () { + // Execute a command with TTY force enabled and check that the prompt is shown. + await execAndWaitForOutputToMatch( + 'ng', + ['deploy'], + /Would you like to add a package with "deploy" capabilities/, + { + ...process.env, + NG_FORCE_TTY: '1', + NG_CLI_ANALYTICS: 'false', + }, + ); + + await killAllProcesses(); + + // Execute a command with TTY force enabled and check that the prompt is shown. + await execAndWaitForOutputToMatch('ng', ['lint'], /Would you like to add ESLint now/, { + ...process.env, + NG_FORCE_TTY: '1', + NG_CLI_ANALYTICS: 'false', + }); +} diff --git a/tests/e2e/tests/misc/browsers.ts b/tests/e2e/tests/misc/browsers.ts new file mode 100644 index 000000000000..1212013a2c99 --- /dev/null +++ b/tests/e2e/tests/misc/browsers.ts @@ -0,0 +1,93 @@ +import express from 'express'; +import * as path from 'node:path'; +import { copyProjectAsset } from '../../utils/assets'; +import { appendToFile, createDir, replaceInFile, writeFile } from '../../utils/fs'; +import { exec, ng } from '../../utils/process'; +import { installPackage } from '../../utils/packages'; + +/** + * The list of development dependencies used by the E2E protractor-based builder. + */ +const E2E_DEV_DEPENDENCIES = [ + 'protractor@~7.0.0', + 'jasmine-spec-reporter@~7.0.0', + 'ts-node@~10.9.0', + '@types/node@^20.17.19', +]; + +export default async function () { + // Ensure SauceLabs configuration + if (!process.env['SAUCE_USERNAME'] || !process.env['SAUCE_ACCESS_KEY']) { + throw new Error('SauceLabs is not configured.'); + } + + for (const e2eDep of E2E_DEV_DEPENDENCIES) { + await installPackage(e2eDep); + } + + await appendToFile( + 'src/app/app.config.ts', + "import { provideProtractorTestingSupport } from '@angular/platform-browser';\n", + ); + await replaceInFile( + 'src/app/app.config.ts', + 'providers: [', + 'providers: [\n provideProtractorTestingSupport(),\n', + ); + + // Workaround for https://github.com/angular/angular/issues/32192 + await replaceInFile('src/app/app.html', /class="material-icons"/g, ''); + + await ng('build'); + + // Add Protractor configuration + await copyProjectAsset('protractor-saucelabs.conf.js', 'e2e/protractor-saucelabs.conf.js'); + + // Add App E2E test file + await createDir('e2e/src'); + await writeFile( + 'e2e/src/app.e2e-spec.ts', + ` +import { browser, by, element, logging } from 'protractor'; + +describe('workspace-project App', () => { + it('should display welcome message', async () => { + await browser.get(browser.baseUrl); + expect((await element(by.css('h1')).getText()).trim()).toEqual('Hello, test-project'); + }); +}); +`, + ); + + // Add App E2E tsconfig file + await writeFile( + 'e2e/tsconfig.json', + ` +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "out-tsc/e2e", + "module": "commonjs", + "target": "es2019", + "types": [ + "jasmine", + "node" + ] + } +} +`, + ); + + // Setup server + const app = express(); + app.use(express.static(path.resolve('dist/test-project/browser'))); + const server = app.listen(2000, 'localhost'); + + try { + // Execute application's E2E tests with SauceLabs + const binPath = path.join('node_modules', '.bin', 'protractor'); + await exec(binPath, 'e2e/protractor-saucelabs.conf.js'); + } finally { + server.close(); + } +} diff --git a/tests/e2e/tests/misc/check-postinstalls.ts b/tests/e2e/tests/misc/check-postinstalls.ts new file mode 100644 index 000000000000..7c1d63e6ef97 --- /dev/null +++ b/tests/e2e/tests/misc/check-postinstalls.ts @@ -0,0 +1,69 @@ +import glob from 'fast-glob'; +import { readFile } from '../../utils/fs'; + +const CURRENT_SCRIPT_PACKAGES: ReadonlySet = new Set([ + '@parcel/watcher (install)', + 'esbuild (postinstall)', + 'lmdb (install)', + 'msgpackr-extract (install)', + 'nice-napi (install)', + 'unrs-resolver (postinstall)', +]); + +const POTENTIAL_SCRIPTS: ReadonlyArray = ['preinstall', 'install', 'postinstall']; + +// Some packages include test and/or example code that causes false positives +const FALSE_POSITIVE_PATHS: ReadonlySet = new Set([ + 'jasmine-spec-reporter/examples/protractor/package.json', + 'resolve/test/resolver/multirepo/package.json', + 'resolve/test/list-exports/packages/tests/fixtures/resolve-1/project/test/resolver/multirepo/package.json', + 'resolve/test/list-exports/packages/tests/fixtures/resolve-2/project/test/resolver/multirepo/package.json', +]); + +const INNER_NODE_MODULES_SEGMENT = '/node_modules/'; + +export default async function () { + const manifestPaths = await glob('node_modules/**/package.json'); + const newPackages: string[] = []; + + for (const manifestPath of manifestPaths) { + const lastNodeModuleIndex = manifestPath.lastIndexOf(INNER_NODE_MODULES_SEGMENT); + const packageRelativePath = manifestPath.slice( + lastNodeModuleIndex === -1 + ? INNER_NODE_MODULES_SEGMENT.length - 1 + : lastNodeModuleIndex + INNER_NODE_MODULES_SEGMENT.length, + ); + if (FALSE_POSITIVE_PATHS.has(packageRelativePath)) { + continue; + } + + let manifest; + try { + manifest = JSON.parse(await readFile(manifestPath)); + } catch { + continue; + } + + if (!manifest.scripts) { + continue; + } + + for (const script of POTENTIAL_SCRIPTS) { + if (!manifest.scripts[script]) { + continue; + } + + const packageScript = `${manifest.name} (${script})`; + + if (!CURRENT_SCRIPT_PACKAGES.has(packageScript)) { + newPackages.push(packageScript + `[${manifestPath}]`); + } + } + } + + if (newPackages.length) { + throw new Error( + 'New install script package(s) detected:\n' + JSON.stringify(newPackages, null, 2), + ); + } +} diff --git a/tests/e2e/tests/misc/create-angular.ts b/tests/e2e/tests/misc/create-angular.ts new file mode 100644 index 000000000000..2b060d90ac33 --- /dev/null +++ b/tests/e2e/tests/misc/create-angular.ts @@ -0,0 +1,53 @@ +import { equal } from 'node:assert'; +import { join } from 'node:path'; +import { expectFileToExist, readFile, rimraf } from '../../utils/fs'; +import { getActivePackageManager } from '../../utils/packages'; +import { silentBun, silentNpm, silentPnpm, silentYarn } from '../../utils/process'; +import { mktempd } from '../../utils/utils'; + +export default async function () { + const currentDirectory = process.cwd(); + const newDirectory = await mktempd('create-angular-test-'); + const projectName = 'test-project-create'; + + try { + process.chdir(newDirectory); + const packageManager = getActivePackageManager(); + + switch (packageManager) { + case 'npm': + await silentNpm('init', '@angular', projectName, '--', '--style=scss'); + + break; + case 'yarn': + await silentYarn('create', '@angular', projectName, '--style=scss'); + + break; + case 'bun': + await silentBun('create', '@angular', projectName, '--style=scss'); + + break; + case 'pnpm': + await silentPnpm('create', '@angular', projectName, '--style=scss'); + + break; + default: + throw new Error(`This test is not configured to use ${packageManager}.`); + } + + // Check that package manager has been configured based on the package manager used to invoke the create command. + const workspace = JSON.parse(await readFile(join(projectName, 'angular.json'))); + equal( + workspace.cli?.packageManager, + packageManager, + `Expected 'packageManager' option to be configured to ${packageManager}.`, + ); + + // Verify styles was create with correct extension. + await expectFileToExist(join(projectName, 'src/styles.scss')); + } finally { + await rimraf(projectName); + // Change directory back + process.chdir(currentDirectory); + } +} diff --git a/tests/e2e/tests/misc/dedupe-duplicate-modules.ts b/tests/e2e/tests/misc/dedupe-duplicate-modules.ts new file mode 100644 index 000000000000..5047021566eb --- /dev/null +++ b/tests/e2e/tests/misc/dedupe-duplicate-modules.ts @@ -0,0 +1,65 @@ +import { expectFileToMatch, rimraf, writeFile } from '../../utils/fs'; +import { gitClean } from '../../utils/git'; +import { installWorkspacePackages } from '../../utils/packages'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + try { + // Force duplicate modules + await updateJsonFile('package.json', (json) => { + json.dependencies = { + ...json.dependencies, + 'tslib': '^2.0.0', + 'tslib-1': 'npm:tslib@1.13.0', + 'tslib-1-copy': 'npm:tslib@1.13.0', + }; + }); + + await installWorkspacePackages(); + + await writeFile( + './src/main.ts', + ` + import { __assign as __assign_0 } from 'tslib'; + import { __assign as __assign_1 } from 'tslib-1'; + import { __assign as __assign_2 } from 'tslib-1-copy'; + + console.log({ + __assign_0, + __assign_1, + __assign_2, + }) + `, + ); + + const { stderr } = await ng( + 'build', + '--verbose', + '--no-vendor-chunk', + '--no-progress', + '--configuration=development', + ); + const outFile = 'dist/test-project/browser/main.js'; + + if (/\[DedupeModuleResolvePlugin\]:.+tslib-1-copy.+ -> .+tslib-1.+/.test(stderr)) { + await expectFileToMatch(outFile, './node_modules/tslib-1/tslib.es6.js'); + await expectToFail(() => + expectFileToMatch(outFile, './node_modules/tslib-1-copy/tslib.es6.js'), + ); + } else if (/\[DedupeModuleResolvePlugin\]:.+tslib-1.+ -> .+tslib-1-copy.+/.test(stderr)) { + await expectFileToMatch(outFile, './node_modules/tslib-1-copy/tslib.es6.js'); + await expectToFail(() => expectFileToMatch(outFile, './node_modules/tslib-1/tslib.es6.js')); + } else { + console.error(`\n\n\n${stderr}\n\n\n`); + throw new Error('Expected stderr to contain [DedupeModuleResolvePlugin] log for tslib.'); + } + + await expectFileToMatch(outFile, './node_modules/tslib/tslib.es6.mjs'); + } finally { + await rimraf('node_modules/tslib'); + await gitClean(); + await installWorkspacePackages(); + } +} diff --git a/tests/e2e/tests/misc/duplicate-command-line-option.ts b/tests/e2e/tests/misc/duplicate-command-line-option.ts new file mode 100644 index 000000000000..0042a363e156 --- /dev/null +++ b/tests/e2e/tests/misc/duplicate-command-line-option.ts @@ -0,0 +1,19 @@ +import { ng } from '../../utils/process'; +import { expectFileToExist } from '../../utils/fs'; + +export default async function () { + const { stderr } = await ng( + 'generate', + 'component', + 'test-component', + '--style=scss', + '--style=sass', + ); + + const warningMatch = `Option 'style' has been specified multiple times. The value 'sass' will be used`; + if (!stderr.includes(warningMatch)) { + throw new Error(`Expected stderr to contain: "${warningMatch}".`); + } + + await expectFileToExist('src/app/test-component/test-component.sass'); +} diff --git a/tests/e2e/tests/misc/es2015-nometa.ts b/tests/e2e/tests/misc/es2015-nometa.ts new file mode 100644 index 000000000000..c8fad0e07954 --- /dev/null +++ b/tests/e2e/tests/misc/es2015-nometa.ts @@ -0,0 +1,18 @@ +import { prependToFile, replaceInFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; + +export default async function () { + await ng('generate', 'service', 'user-service'); + + // Update the application to use the new service + await prependToFile('src/app/app.ts', "import { UserService } from './user-service';"); + + await replaceInFile( + 'src/app/app.ts', + 'export class App {', + 'export class App {\n constructor(user: UserService) {}', + ); + + // Execute the application's tests with emitDecoratorMetadata disabled (default) + await ng('test', '--no-watch'); +} diff --git a/tests/e2e/tests/misc/forwardref-es2015.ts b/tests/e2e/tests/misc/forwardref-es2015.ts new file mode 100644 index 000000000000..48c28086cd0d --- /dev/null +++ b/tests/e2e/tests/misc/forwardref-es2015.ts @@ -0,0 +1,43 @@ +import { appendToFile, replaceInFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + // Update the application to use a forward reference + await replaceInFile( + 'src/app/app.ts', + "import { Component, signal } from '@angular/core';", + "import { Component, Inject, Injectable, forwardRef, signal } from '@angular/core';", + ); + await appendToFile('src/app/app.ts', '\n@Injectable() export class Lock { }\n'); + await replaceInFile( + 'src/app/app.ts', + 'export class App {', + 'export class App {\n constructor(@Inject(forwardRef(() => Lock)) lock: Lock) {}', + ); + + // Update the application's unit tests to include the new injectable + await replaceInFile( + 'src/app/app.spec.ts', + "import { App } from './app';", + "import { App, Lock } from './app';", + ); + await replaceInFile( + 'src/app/app.spec.ts', + 'TestBed.configureTestingModule({', + 'TestBed.configureTestingModule({ providers: [Lock],', + ); + + // Execute the application's tests with emitDecoratorMetadata disabled (default) + await ng('test', '--no-watch'); + + // Turn on emitDecoratorMetadata + await replaceInFile( + 'tsconfig.json', + '"experimentalDecorators": true', + '"experimentalDecorators": true, "emitDecoratorMetadata": true', + ); + + // Execute the application's tests with emitDecoratorMetadata enabled + await expectToFail(() => ng('test', '--no-watch')); +} diff --git a/tests/e2e/tests/misc/invalid-schematic-dependencies.ts b/tests/e2e/tests/misc/invalid-schematic-dependencies.ts new file mode 100644 index 000000000000..88300951965e --- /dev/null +++ b/tests/e2e/tests/misc/invalid-schematic-dependencies.ts @@ -0,0 +1,60 @@ +import { join } from 'node:path'; +import { expectFileToMatch } from '../../utils/fs'; +import { + execWithEnv, + extractCIAndInfraEnv, + extractNpmEnv, + ng, + silentNpm, +} from '../../utils/process'; +import { getActivePackageManager, installPackage, uninstallPackage } from '../../utils/packages'; +import { isPrereleaseCli } from '../../utils/project'; +import { appendFile, writeFile } from 'node:fs/promises'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + // Must publish old version to local registry to allow install. This is especially important + // for release commits as npm will try to request tooling packages that are not on the npm registry yet + await publishOutdated('@schematics/angular@7'); + await publishOutdated('@angular-devkit/core@7'); + await publishOutdated('@angular-devkit/schematics@7'); + + // Install outdated and incompatible version + await installPackage('@schematics/angular@7'); + + const isPrerelease = await isPrereleaseCli(); + const tag = isPrerelease ? '@next' : ''; + if (getActivePackageManager() === 'npm') { + await appendFile('.npmrc', '\nlegacy-peer-deps=true'); + } + + await ng('add', `@angular/material${tag}`, '--skip-confirmation'); + await expectFileToMatch('package.json', /@angular\/material/); + + // Clean up existing cdk package + // Not doing so can cause adding material to fail if an incompatible cdk is present + await uninstallPackage('@angular/cdk'); +} + +async function publishOutdated(npmSpecifier: string): Promise { + const npmrc = join(getGlobalVariable('tmp-root'), '.npmrc-publish'); + const testRegistry = (getGlobalVariable('package-registry') as string).replace(/^\w+:/, ''); + await writeFile( + npmrc, + ` + ${testRegistry.replace(/^https?:/, '')}/:_authToken=fake-secret + `, + ); + + const { stdout: stdoutPack } = await silentNpm( + 'pack', + npmSpecifier, + '--registry=https://registry.npmjs.org', + ); + + await execWithEnv('npm', ['publish', stdoutPack.trim(), '--tag=outdated'], { + ...extractNpmEnv(), + ...extractCIAndInfraEnv(), + 'NPM_CONFIG_USERCONFIG': npmrc, + }); +} diff --git a/tests/e2e/tests/misc/loaders-resolution.ts b/tests/e2e/tests/misc/loaders-resolution.ts new file mode 100644 index 000000000000..b411ab60514a --- /dev/null +++ b/tests/e2e/tests/misc/loaders-resolution.ts @@ -0,0 +1,45 @@ +import { createDir, moveFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { assertIsError } from '../../utils/utils'; + +export default async function () { + await createDir('node_modules/@angular-devkit/build-angular/node_modules'); + let originalInRootNodeModules = true; + + try { + await moveFile( + 'node_modules/@ngtools', + 'node_modules/@angular-devkit/build-angular/node_modules/@ngtools', + ); + } catch (e) { + assertIsError(e); + + if (e.code !== 'ENOENT') { + throw e; + } + + // In some cases due to module resolution '@ngtools' might already been under `@angular-devkit/build-angular`. + originalInRootNodeModules = false; + await moveFile( + 'node_modules/@angular-devkit/build-angular/node_modules/@ngtools', + 'node_modules/@ngtools', + ); + } + + await ng('build', '--configuration=development'); + + // Move it back. + await moveBack(originalInRootNodeModules); +} + +function moveBack(originalInRootNodeModules: Boolean): Promise { + return originalInRootNodeModules + ? moveFile( + 'node_modules/@angular-devkit/build-angular/node_modules/@ngtools', + 'node_modules/@ngtools', + ) + : moveFile( + 'node_modules/@ngtools', + 'node_modules/@angular-devkit/build-angular/node_modules/@ngtools', + ); +} diff --git a/tests/e2e/tests/misc/module-resolution/module-resolution-core-mapping.ts b/tests/e2e/tests/misc/module-resolution/module-resolution-core-mapping.ts new file mode 100644 index 000000000000..d2710b07b8be --- /dev/null +++ b/tests/e2e/tests/misc/module-resolution/module-resolution-core-mapping.ts @@ -0,0 +1,41 @@ +import { createDir, moveFile } from '../../../utils/fs'; +import { ng } from '../../../utils/process'; +import { updateJsonFile } from '../../../utils/project'; +import { expectToFail } from '../../../utils/utils'; + +export default async function () { + await updateJsonFile('tsconfig.json', (tsconfig) => { + tsconfig.compilerOptions.paths = { + '*': ['./node_modules/*'], + }; + }); + await ng('build', '--configuration=development'); + + await createDir('xyz'); + await moveFile('node_modules/@angular/platform-browser', 'xyz/platform-browser'); + await expectToFail(() => ng('build', '--configuration=development')); + + await updateJsonFile('tsconfig.json', (tsconfig) => { + tsconfig.compilerOptions.paths = { + '@angular/platform-browser': ['./xyz/platform-browser'], + }; + }); + await ng('build', '--configuration=development'); + + await updateJsonFile('tsconfig.json', (tsconfig) => { + tsconfig.compilerOptions.paths = { + '*': ['./node_modules/*'], + '@angular/platform-browser': ['./xyz/platform-browser'], + }; + }); + await ng('build', '--configuration=development'); + + await updateJsonFile('tsconfig.json', (tsconfig) => { + tsconfig.compilerOptions.paths = { + '@angular/platform-browser': ['./xyz/platform-browser'], + '*': ['./node_modules/*'], + }; + }); + await ng('build', '--configuration=development'); + await moveFile('xyz/platform-browser', 'node_modules/@angular/platform-browser'); +} diff --git a/tests/e2e/tests/misc/multiple-targets.ts b/tests/e2e/tests/misc/multiple-targets.ts new file mode 100644 index 000000000000..a99a37d54b06 --- /dev/null +++ b/tests/e2e/tests/misc/multiple-targets.ts @@ -0,0 +1,9 @@ +import { expectFileToExist } from '../../utils/fs'; +import { ng } from '../../utils/process'; + +export default async function () { + await ng('generate', 'app', 'secondary-app'); + await ng('build', 'secondary-app', '--configuration=development'); + await expectFileToExist('dist/secondary-app/browser/index.html'); + await expectFileToExist('dist/secondary-app/browser/main.js'); +} diff --git a/tests/e2e/tests/misc/negated-boolean-options.ts b/tests/e2e/tests/misc/negated-boolean-options.ts new file mode 100644 index 000000000000..377967785496 --- /dev/null +++ b/tests/e2e/tests/misc/negated-boolean-options.ts @@ -0,0 +1,18 @@ +import { copyAssets } from '../../utils/assets'; +import { execAndWaitForOutputToMatch } from '../../utils/process'; + +export default async function () { + await copyAssets('schematic-boolean-option-negated', 'schematic-boolean-option-negated'); + + await execAndWaitForOutputToMatch( + 'ng', + ['generate', './schematic-boolean-option-negated:test', '--no-watch'], + /noWatch: true/, + ); + + await execAndWaitForOutputToMatch( + 'ng', + ['generate', './schematic-boolean-option-negated:test', '--watch'], + /noWatch: false/, + ); +} diff --git a/tests/e2e/tests/misc/nested-schematic-packages.ts b/tests/e2e/tests/misc/nested-schematic-packages.ts new file mode 100644 index 000000000000..3398a293c837 --- /dev/null +++ b/tests/e2e/tests/misc/nested-schematic-packages.ts @@ -0,0 +1,11 @@ +import { copyAssets } from '../../utils/assets'; +import { expectFileToExist } from '../../utils/fs'; +import { ng } from '../../utils/process'; + +export default async function () { + await copyAssets('nested-schematic-main', 'nested'); + await copyAssets('nested-schematic-dependency', 'nested/node_modules/empty-app-nested'); + + await ng('generate', './nested:test'); + await expectFileToExist('empty-file'); +} diff --git a/tests/e2e/tests/misc/supported-angular.ts b/tests/e2e/tests/misc/supported-angular.ts new file mode 100644 index 000000000000..d5299485bb7f --- /dev/null +++ b/tests/e2e/tests/misc/supported-angular.ts @@ -0,0 +1,37 @@ +import { SemVer } from 'semver'; +import { getGlobalVariable } from '../../utils/env'; +import { readFile, writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + if (getGlobalVariable('argv')['ng-snapshots']) { + // The snapshots job won't work correctly because it doesn't use semver for Angular. + return; + } + + const angularCliPkgJson = JSON.parse(await readFile('node_modules/@angular/cli/package.json')); + const cliMajor = new SemVer(angularCliPkgJson.version as string).major; + const angularCorePkgPath = 'node_modules/@angular/core/package.json'; + const originalAngularCorePkgJson = await readFile(angularCorePkgPath); + + // Fake version by writing over the @angular/core version, since that's what the CLI checks. + const fakeCoreVersion = async (newMajor: number) => { + const tmpPkgJson = JSON.parse(originalAngularCorePkgJson); + tmpPkgJson.version = `${newMajor}.0.0`; + await writeFile(angularCorePkgPath, JSON.stringify(tmpPkgJson)); + }; + + // Major should succeed, but we don't need to test it here since it's tested everywhere else. + // Major+1 and -1 should fail architect commands, but allow other commands. + try { + await fakeCoreVersion(cliMajor + 1); + await expectToFail(() => ng('build'), 'Should fail Major+1'); + await ng('version'); + await fakeCoreVersion(cliMajor - 1); + await ng('version'); + } finally { + // Restore the original core package.json. + await writeFile(angularCorePkgPath, originalAngularCorePkgJson); + } +} diff --git a/tests/e2e/tests/misc/target-default-configuration.ts b/tests/e2e/tests/misc/target-default-configuration.ts new file mode 100644 index 000000000000..d5f381b2db97 --- /dev/null +++ b/tests/e2e/tests/misc/target-default-configuration.ts @@ -0,0 +1,35 @@ +import { expectFileToExist } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + await updateJsonFile('angular.json', (workspace) => { + const build = workspace.projects['test-project'].architect.build; + build.defaultConfiguration = undefined; + build.options = { + ...build.options, + optimization: false, + buildOptimizer: false, + outputHashing: 'none', + sourceMap: true, + }; + }); + + await ng('build'); + await expectFileToExist('dist/test-project/browser/main.js'); + await expectFileToExist('dist/test-project/browser/main.js.map'); + + // Add new configuration and set "defaultConfiguration" + await updateJsonFile('angular.json', (workspace) => { + const build = workspace.projects['test-project'].architect.build; + build.defaultConfiguration = 'foo'; + build.configurations.foo = { + sourceMap: false, + }; + }); + + await ng('build'); + await expectFileToExist('dist/test-project/browser/main.js'); + await expectToFail(() => expectFileToExist('dist/test-project/browser/main.js.map')); +} diff --git a/tests/e2e/tests/misc/trace-resolution.ts b/tests/e2e/tests/misc/trace-resolution.ts new file mode 100644 index 000000000000..0827223e58a0 --- /dev/null +++ b/tests/e2e/tests/misc/trace-resolution.ts @@ -0,0 +1,22 @@ +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + await updateJsonFile('tsconfig.json', (tsconfig) => { + tsconfig.compilerOptions.traceResolution = true; + }); + + const { stdout: stdoutTraced } = await ng('build', '--configuration=development'); + if (!/Resolving module/.test(stdoutTraced)) { + throw new Error(`Modules resolutions must be printed when 'traceResolution' is enabled.`); + } + + await updateJsonFile('tsconfig.json', (tsconfig) => { + tsconfig.compilerOptions.traceResolution = false; + }); + + const { stdout: stdoutUnTraced } = await ng('build', '--configuration=development'); + if (/Resolving module/.test(stdoutUnTraced)) { + throw new Error(`Modules resolutions must not be printed when 'traceResolution' is disabled.`); + } +} diff --git a/tests/e2e/tests/misc/trusted-types.ts b/tests/e2e/tests/misc/trusted-types.ts new file mode 100644 index 000000000000..21deb03223dd --- /dev/null +++ b/tests/e2e/tests/misc/trusted-types.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert/strict'; +import { replaceInFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + // Add lazy route. + await ng('generate', 'component', 'lazy'); + await replaceInFile( + 'src/app/app.routes.ts', + 'routes: Routes = [];', + `routes: Routes = [{path: 'lazy', loadComponent: () => import('./lazy/lazy').then(c => c.Lazy)}];`, + ); + + const testCases = [ + { + aot: false, + csp: `trusted-types angular angular#unsafe-bypass angular#unsafe-jit angular#bundler; require-trusted-types-for 'script';`, + }, + { + aot: true, + csp: `trusted-types angular angular#unsafe-bypass angular#bundler; require-trusted-types-for 'script';`, + }, + ]; + + for (const { aot, csp } of testCases) { + await updateJsonFile('angular.json', (json) => { + const architect = json['projects']['test-project']['architect']; + architect['build']['options']['aot'] = aot; + if (!architect['serve']['options']) architect['serve']['options'] = {}; + architect['serve']['options']['headers'] = { + 'Content-Security-Policy': csp, + }; + }); + + try { + await executeBrowserTest({ + checkFn: async (page) => { + const baseUrl = page.url(); + await page.goto(new URL('/lazy', baseUrl).href); + + await page.waitForSelector('app-lazy p'); + const lazyText = await page.$eval('app-lazy p', (el) => el.textContent); + assert.strictEqual(lazyText, 'lazy works!'); + }, + }); + } catch (error) { + console.error(`Test case AOT ${aot} with CSP header ${csp} failed.`); + throw error; + } + } +} diff --git a/tests/e2e/tests/misc/update-git-clean-subdirectory.ts b/tests/e2e/tests/misc/update-git-clean-subdirectory.ts new file mode 100644 index 000000000000..733a91164019 --- /dev/null +++ b/tests/e2e/tests/misc/update-git-clean-subdirectory.ts @@ -0,0 +1,25 @@ +import { getGlobalVariable } from '../../utils/env'; +import { createDir, writeFile } from '../../utils/fs'; +import { ng, silentGit } from '../../utils/process'; +import { prepareProjectForE2e } from '../../utils/project'; + +export default async function () { + process.chdir(getGlobalVariable('projects-root')); + + await createDir('./subdirectory'); + process.chdir('./subdirectory'); + + await silentGit('init', '.'); + + await ng('new', 'subdirectory-test-project', '--skip-install', '--test-runner', 'karma'); + process.chdir('./subdirectory-test-project'); + await prepareProjectForE2e('subdirectory-test-project'); + + await writeFile('../added.ts', "console.log('created');\n"); + await silentGit('add', '../added.ts'); + + const { stderr } = await ng('update', '@angular/cli'); + if (stderr && stderr.includes('Repository is not clean.')) { + throw new Error('Expected clean repository'); + } +} diff --git a/tests/e2e/tests/misc/update-git-clean.ts b/tests/e2e/tests/misc/update-git-clean.ts new file mode 100644 index 000000000000..c992c695c4e3 --- /dev/null +++ b/tests/e2e/tests/misc/update-git-clean.ts @@ -0,0 +1,12 @@ +import { appendToFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + await appendToFile('src/main.ts', "console.log('changed');\n"); + + const { message } = await expectToFail(() => ng('update', '@angular/cli')); + if (!message || !message.includes('Repository is not clean.')) { + throw new Error('Expected unclean repository'); + } +} diff --git a/tests/e2e/tests/misc/version.ts b/tests/e2e/tests/misc/version.ts new file mode 100644 index 000000000000..4ad57adc9726 --- /dev/null +++ b/tests/e2e/tests/misc/version.ts @@ -0,0 +1,22 @@ +import { deleteFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; + +export default async function () { + const { stdout: commandOutput } = await ng('version'); + + if (commandOutput.includes(process.versions.node + ' (Unsupported)')) { + throw new Error('Node version should not show unsupported entry'); + } + + if (commandOutput.includes('Warning: The current version of Node ')) { + throw new Error('Node support warning should not be shown'); + } + + // doesn't fail on a project with missing angular.json + await deleteFile('angular.json'); + await ng('version'); + + // Doesn't fail outside a project. + process.chdir('/'); + await ng('version'); +} diff --git a/tests/e2e/tests/misc/workspace-verification.ts b/tests/e2e/tests/misc/workspace-verification.ts new file mode 100644 index 000000000000..bf55841a9398 --- /dev/null +++ b/tests/e2e/tests/misc/workspace-verification.ts @@ -0,0 +1,14 @@ +import { deleteFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default function () { + return ( + ng('generate', 'component', 'foo', '--dry-run') + .then(() => deleteFile('angular.json')) + // fails because it needs to be inside a project + // without a workspace file + .then(() => expectToFail(() => ng('generate', 'component', 'foo', '--dry-run'))) + .then(() => ng('version')) + ); +} diff --git a/tests/e2e/tests/schematics_cli/basic.ts b/tests/e2e/tests/schematics_cli/basic.ts new file mode 100644 index 000000000000..62128e1e32db --- /dev/null +++ b/tests/e2e/tests/schematics_cli/basic.ts @@ -0,0 +1,37 @@ +import { join } from 'node:path'; +import { getGlobalVariable } from '../../utils/env'; +import { exec, execAndWaitForOutputToMatch, silentNpm } from '../../utils/process'; +import { rimraf } from '../../utils/fs'; + +export default async function () { + // setup + const argv = getGlobalVariable('argv'); + if (argv.noglobal) { + return; + } + + await silentNpm('install', '-g', '@angular-devkit/schematics-cli'); + await exec(process.platform.startsWith('win') ? 'where' : 'which', 'schematics'); + + const startCwd = process.cwd(); + const schematicPath = join(startCwd, 'test-schematic'); + + try { + // create blank schematic + await exec('schematics', 'schematic', '--name', 'test-schematic'); + + process.chdir(join(startCwd, 'test-schematic')); + await execAndWaitForOutputToMatch( + 'schematics', + ['.:', '--list-schematics'], + /my-full-schematic/, + ); + } finally { + // restore path + process.chdir(startCwd); + await Promise.all([ + rimraf(schematicPath), + silentNpm('uninstall', '-g', '@angular-devkit/schematics-cli'), + ]); + } +} diff --git a/tests/e2e/tests/schematics_cli/blank-test.ts b/tests/e2e/tests/schematics_cli/blank-test.ts new file mode 100644 index 000000000000..4087f7476db9 --- /dev/null +++ b/tests/e2e/tests/schematics_cli/blank-test.ts @@ -0,0 +1,34 @@ +import { join } from 'node:path'; +import { getGlobalVariable } from '../../utils/env'; +import { exec, silentNpm } from '../../utils/process'; +import { rimraf } from '../../utils/fs'; + +export default async function () { + // setup + const argv = getGlobalVariable('argv'); + if (argv.noglobal) { + return; + } + + await silentNpm('install', '-g', '@angular-devkit/schematics-cli'); + await exec(process.platform.startsWith('win') ? 'where' : 'which', 'schematics'); + + const startCwd = process.cwd(); + const schematicPath = join(startCwd, 'test-schematic'); + + try { + // create schematic + await exec('schematics', 'blank', '--name', 'test-schematic'); + + process.chdir(schematicPath); + + await silentNpm('test'); + } finally { + // restore path + process.chdir(startCwd); + await Promise.all([ + rimraf(schematicPath), + silentNpm('uninstall', '-g', '@angular-devkit/schematics-cli'), + ]); + } +} diff --git a/tests/e2e/tests/schematics_cli/schematic-test.ts b/tests/e2e/tests/schematics_cli/schematic-test.ts new file mode 100644 index 000000000000..7d7d26e7304b --- /dev/null +++ b/tests/e2e/tests/schematics_cli/schematic-test.ts @@ -0,0 +1,34 @@ +import { join } from 'node:path'; +import { getGlobalVariable } from '../../utils/env'; +import { exec, silentNpm } from '../../utils/process'; +import { rimraf } from '../../utils/fs'; + +export default async function () { + // setup + const argv = getGlobalVariable('argv'); + if (argv.noglobal) { + return; + } + + await silentNpm('install', '-g', '@angular-devkit/schematics-cli'); + await exec(process.platform.startsWith('win') ? 'where' : 'which', 'schematics'); + + const startCwd = process.cwd(); + const schematicPath = join(startCwd, 'test-schematic'); + + try { + // create schematic + await exec('schematics', 'schematic', '--name', 'test-schematic'); + + process.chdir(schematicPath); + + await silentNpm('test'); + } finally { + // restore path + process.chdir(startCwd); + await Promise.all([ + rimraf(schematicPath), + silentNpm('uninstall', '-g', '@angular-devkit/schematics-cli'), + ]); + } +} diff --git a/tests/e2e/tests/test/karma-junit-output.ts b/tests/e2e/tests/test/karma-junit-output.ts new file mode 100644 index 000000000000..dbc61c3fafc5 --- /dev/null +++ b/tests/e2e/tests/test/karma-junit-output.ts @@ -0,0 +1,27 @@ +import { expectFileMatchToExist, replaceInFile } from '../../utils/fs'; +import { installPackage } from '../../utils/packages'; +import { silentNg } from '../../utils/process'; + +const E2E_CUSTOM_LAUNCHER = ` + customLaunchers: { + ChromeHeadlessNoSandbox: { + base: 'ChromeHeadless', + flags: ['--no-sandbox', '--headless', '--disable-gpu', '--disable-dev-shm-usage'], + }, + }, + restartOnFileChange: true +`; + +export default async function () { + await installPackage('karma-junit-reporter'); + await silentNg('generate', 'config', 'karma'); + + await replaceInFile('karma.conf.js', 'karma-jasmine-html-reporter', 'karma-junit-reporter'); + await replaceInFile('karma.conf.js', `'kjhtml'`, `'junit'`); + + await replaceInFile('karma.conf.js', `restartOnFileChange: true`, E2E_CUSTOM_LAUNCHER); + + await silentNg('test', '--no-watch'); + + await expectFileMatchToExist('.', /TESTS\-.+\.xml/); +} diff --git a/tests/e2e/tests/test/test-code-coverage-exclude.ts b/tests/e2e/tests/test/test-code-coverage-exclude.ts new file mode 100644 index 000000000000..808bcb59e2d9 --- /dev/null +++ b/tests/e2e/tests/test/test-code-coverage-exclude.ts @@ -0,0 +1,26 @@ +import { getGlobalVariable } from '../../utils/env'; +import { expectFileToExist, rimraf } from '../../utils/fs'; +import { silentNg } from '../../utils/process'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + const isWebpack = !getGlobalVariable('argv')['esbuild']; + const coverageOptionName = isWebpack ? '--code-coverage' : '--coverage'; + + // This test is already in build-angular, but that doesn't run on Windows. + await silentNg('test', '--no-watch', coverageOptionName); + await expectFileToExist('coverage/test-project/app.ts.html'); + // Delete coverage directory + await rimraf('coverage'); + + await silentNg( + 'test', + '--no-watch', + coverageOptionName, + `${coverageOptionName}-exclude='src/**/app.ts'`, + ); + + // Doesn't include excluded. + await expectFileToExist('coverage/test-project/index.html'); + await expectToFail(() => expectFileToExist('coverage/test-project/app.ts.html')); +} diff --git a/tests/e2e/tests/test/test-environment.ts b/tests/e2e/tests/test/test-environment.ts new file mode 100644 index 000000000000..1670cb246521 --- /dev/null +++ b/tests/e2e/tests/test/test-environment.ts @@ -0,0 +1,71 @@ +import { ng } from '../../utils/process'; +import { writeFile, writeMultipleFiles } from '../../utils/fs'; +import { updateJsonFile } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + const isWebpack = !getGlobalVariable('argv')['esbuild']; + + // Tests run in 'dev' environment by default. + await writeMultipleFiles({ + 'src/environment.prod.ts': ` + export const environment = { + production: true + };`, + 'src/environment.ts': ` + export const environment = { + production: false + }; + `, + 'src/app/environment.spec.ts': ` + import { environment } from '../environment'; + + describe('Test environment', () => { + it('should have production disabled', () => { + expect(environment.production).toBe(false); + }); + }); + `, + }); + + await ng('test', '--watch=false'); + + await updateJsonFile('angular.json', (configJson) => { + const appArchitect = configJson.projects['test-project'].architect; + appArchitect[isWebpack ? 'test' : 'build'].configurations = { + production: { + fileReplacements: [ + { + replace: 'src/environment.ts', + with: 'src/environment.prod.ts', + }, + ], + }, + }; + if (!isWebpack) { + appArchitect.test.options ??= {}; + appArchitect.test.options.buildTarget = '::production'; + } + }); + + // Tests can run in different environment. + + await writeFile( + 'src/app/environment.spec.ts', + ` + import { environment } from '../environment'; + + describe('Test environment', () => { + it('should have production enabled', () => { + expect(environment.production).toBe(true); + }); + }); + `, + ); + + if (isWebpack) { + await ng('test', '--watch=false', '--configuration=production'); + } else { + await ng('test', '--watch=false'); + } +} diff --git a/tests/e2e/tests/test/test-fail-single-run.ts b/tests/e2e/tests/test/test-fail-single-run.ts new file mode 100644 index 000000000000..d2054e7c37ee --- /dev/null +++ b/tests/e2e/tests/test/test-fail-single-run.ts @@ -0,0 +1,12 @@ +import { ng } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { expectToFail } from '../../utils/utils'; + +export default function () { + // TODO(architect): Delete this test. It is now in devkit/build-angular. + + // Fails on single run with broken compilation. + return writeFile('src/app.spec.ts', '

definitely not typescript

').then(() => + expectToFail(() => ng('test', '--watch=false')), + ); +} diff --git a/tests/e2e/tests/test/test-include-glob.ts b/tests/e2e/tests/test/test-include-glob.ts new file mode 100644 index 000000000000..5dc55edbf8c7 --- /dev/null +++ b/tests/e2e/tests/test/test-include-glob.ts @@ -0,0 +1,5 @@ +import { ng } from '../../utils/process'; + +export default async function () { + await ng('test', '--no-watch', `--include='**/*.spec.ts'`); +} diff --git a/tests/e2e/tests/test/test-jasmine-clock.ts b/tests/e2e/tests/test/test-jasmine-clock.ts new file mode 100644 index 000000000000..37b164ff5914 --- /dev/null +++ b/tests/e2e/tests/test/test-jasmine-clock.ts @@ -0,0 +1,33 @@ +import { ng } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; + +export default async function () { + await writeFile( + 'src/app/app.spec.ts', + ` + import { TestBed } from '@angular/core/testing'; + import { App } from './app'; + + describe('App', () => { + beforeAll(() => { + jasmine.clock().install(); + }); + + afterAll(() => { + jasmine.clock().uninstall(); + }); + + beforeEach(() => TestBed.configureTestingModule({ + imports: [App] + })); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + expect(fixture.componentInstance).toBeTruthy(); + }); + }); + `, + ); + + await ng('test', '--watch=false'); +} diff --git a/tests/e2e/tests/test/test-scripts.ts b/tests/e2e/tests/test/test-scripts.ts new file mode 100644 index 000000000000..953ed52cfbd7 --- /dev/null +++ b/tests/e2e/tests/test/test-scripts.ts @@ -0,0 +1,71 @@ +import { getGlobalVariable } from '../../utils/env'; +import { writeMultipleFiles } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + // prepare global scripts test files + await writeMultipleFiles({ + 'src/string-script.js': `globalThis.stringScriptGlobal = 'string-scripts.js';`, + 'src/input-script.js': `globalThis.inputScriptGlobal = 'input-scripts.js';`, + 'src/typings.d.ts': ` + declare var stringScriptGlobal: any; + declare var inputScriptGlobal: any; + `, + 'src/app/app.ts': ` + import { Component } from '@angular/core'; + + @Component({ selector: 'app-root', template: '', standalone: false }) + export class App { + stringScriptGlobalProp = stringScriptGlobal; + inputScriptGlobalProp = inputScriptGlobal; + } + `, + 'src/app/app.spec.ts': ` + import { TestBed } from '@angular/core/testing'; + import { App } from './app'; + + describe('App', () => { + beforeEach(() => TestBed.configureTestingModule({ + declarations: [App] + })); + + it('should have access to string-script.js', () => { + let app = TestBed.createComponent(App).debugElement.componentInstance; + expect(app.stringScriptGlobalProp).toEqual('string-scripts.js'); + }); + + it('should have access to input-script.js', () => { + let app = TestBed.createComponent(App).debugElement.componentInstance; + expect(app.inputScriptGlobalProp).toEqual('input-scripts.js'); + }); + }); + + describe('Spec', () => { + it('should have access to string-script.js', () => { + expect(stringScriptGlobal).toBe('string-scripts.js'); + }); + + it('should have access to input-script.js', () => { + expect(inputScriptGlobal).toBe('input-scripts.js'); + }); + }); + `, + }); + + // should fail because the global scripts were not added to scripts array + await expectToFail(() => ng('test', '--watch=false')); + + const isWebpack = !getGlobalVariable('argv')['esbuild']; + await updateJsonFile('angular.json', (workspaceJson) => { + const appArchitect = workspaceJson.projects['test-project'].architect; + appArchitect[isWebpack ? 'test' : 'build'].options.scripts = [ + { input: 'src/string-script.js' }, + { input: 'src/input-script.js' }, + ]; + }); + + // should pass now + await ng('test', '--watch=false'); +} diff --git a/tests/e2e/tests/test/test-sourcemap.ts b/tests/e2e/tests/test/test-sourcemap.ts new file mode 100644 index 000000000000..5d4e668c54b6 --- /dev/null +++ b/tests/e2e/tests/test/test-sourcemap.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert'; +import { writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { assertIsError } from '../../utils/utils'; +import { updateJsonFile } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + const isWebpack = !getGlobalVariable('argv')['esbuild']; + + await writeFile( + 'src/app/app.spec.ts', + ` + it('should fail', () => { + expect(undefined).toBeTruthy(); + }); + `, + ); + + // when sourcemaps are 'on' the stacktrace will point to the spec.ts file. + await updateJsonFile('angular.json', (configJson) => { + const appArchitect = configJson.projects['test-project'].architect; + if (isWebpack) { + appArchitect['test'].options.sourceMap = true; + } else { + appArchitect['build'].configurations.development.sourceMap = true; + } + }); + try { + await ng('test', '--no-watch'); + throw new Error('ng test should have failed.'); + } catch (error) { + assertIsError(error); + assert.match(error.message, /\(src\/app\/app\.spec\.ts:3:27/); + assert.doesNotMatch(error.message, /webpack/); + } + + // when sourcemaps are 'off' the stacktrace won't point to the spec.ts file. + await updateJsonFile('angular.json', (configJson) => { + const appArchitect = configJson.projects['test-project'].architect; + if (isWebpack) { + appArchitect['test'].options.sourceMap = false; + } else { + appArchitect['build'].configurations.development.sourceMap = false; + } + }); + try { + await ng('test', '--no-watch'); + throw new Error('ng test should have failed.'); + } catch (error) { + assertIsError(error); + assert.match(error.message, /main\.js/); + } +} diff --git a/tests/e2e/tests/update/update-application-builder.ts b/tests/e2e/tests/update/update-application-builder.ts new file mode 100644 index 000000000000..2769fe943138 --- /dev/null +++ b/tests/e2e/tests/update/update-application-builder.ts @@ -0,0 +1,36 @@ +import { match } from 'node:assert'; +import { createProjectFromAsset } from '../../utils/assets'; +import { + expectFileMatchToExist, + expectFileNotToExist, + expectFileToExist, + expectFileToMatch, +} from '../../utils/fs'; +import { execAndWaitForOutputToMatch, ng, noSilentNg } from '../../utils/process'; +import { findFreePort } from '../../utils/network'; + +export default async function () { + await createProjectFromAsset('ssr-project-webpack', false, false); + await ng('update', `@angular/cli`, '--name=use-application-builder'); + + await Promise.all([ + expectFileNotToExist('tsconfig.server.json'), + expectFileToMatch('tsconfig.json', 'esModuleInterop'), + expectFileToMatch('src/server.ts', 'import.meta.url'), + ]); + + // Verify project now creates bundles + await noSilentNg('build', '--configuration=production'); + + await Promise.all([ + expectFileToExist('dist/ssr-project-webpack/server/server.mjs'), + expectFileMatchToExist('dist/ssr-project-webpack/browser', /main-[a-zA-Z0-9]{8}\.js/), + ]); + + // Verify that the app runs + const port = await findFreePort(); + await execAndWaitForOutputToMatch('ng', ['serve', '--port', String(port)], /complete\./); + const response = await fetch(`http://localhost:${port}/`); + const text = await response.text(); + match(text, /app is running!/); +} diff --git a/tests/e2e/tests/update/update-multiple-versions.ts b/tests/e2e/tests/update/update-multiple-versions.ts new file mode 100644 index 000000000000..c9070b1a090b --- /dev/null +++ b/tests/e2e/tests/update/update-multiple-versions.ts @@ -0,0 +1,43 @@ +import { createProjectFromAsset } from '../../utils/assets'; +import { setRegistry } from '../../utils/packages'; +import { ng } from '../../utils/process'; +import { isPrereleaseCli } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; + +export default async function () { + let restoreRegistry: (() => Promise) | undefined; + try { + restoreRegistry = await createProjectFromAsset('20.0-project', true); + await setRegistry(true); + + const extraArgs = ['--force']; + if (isPrereleaseCli()) { + extraArgs.push('--next'); + } + + // TODO(alanagius): investigate how to re-enable this. This is failing but it's correct since we are using the public registry. + // Update Angular from v13 to 14 + // const { stdout } = await ng('update', ...extraArgs); + // if (!/@angular\/core\s+13\.\d\.\d+ -> 14\.\d\.\d+\s+ng update @angular\/core@14/.test(stdout)) { + // // @angular/core 13.x.x -> 14.x.x ng update @angular/core@14 + // throw new Error( + // `Output didn't match "@angular/core 13.x.x -> 14.x.x ng update @angular/core@14". OUTPUT: \n` + + // stdout, + // ); + // } + + const { message } = await expectToFail(() => ng('update', '@angular/core', ...extraArgs)); + if ( + !message.includes( + `Updating multiple major versions of '@angular/core' at once is not supported`, + ) + ) { + throw new Error( + `Expected error message to include "Updating multiple major versions of '@angular/core' at once is not supported" but didn't. OUTPUT: \n` + + message, + ); + } + } finally { + await restoreRegistry?.(); + } +} diff --git a/tests/e2e/tests/update/update-pnpm-workspace.ts b/tests/e2e/tests/update/update-pnpm-workspace.ts new file mode 100644 index 000000000000..29aff7bff42a --- /dev/null +++ b/tests/e2e/tests/update/update-pnpm-workspace.ts @@ -0,0 +1,33 @@ +import { createProjectFromAsset } from '../../utils/assets'; +import { readFile, writeFile } from '../../utils/fs'; +import { getActivePackageManager } from '../../utils/packages'; +import { ng } from '../../utils/process'; + +export default async function () { + if (getActivePackageManager() !== 'pnpm') { + return; + } + + let restoreRegistry: (() => Promise) | undefined; + + try { + // Setup project from older asset using the public registry + restoreRegistry = await createProjectFromAsset('20.0-project', true); + + // Create pnpm-workspace.yaml inside the project directory + await writeFile('pnpm-workspace.yaml', "packages:\n - '.'\n"); + + // Run ng update on @angular/cli to trigger the update from version 20 to the next major version + await ng('update', '@angular/cli@21', '@angular/core@21'); + + // Verify that the pnpm lockfile does not contain references to the temporary package directory + const lockfileContent = await readFile('pnpm-lock.yaml'); + if (lockfileContent.includes('angular-cli-tmp-packages-')) { + throw new Error( + 'pnpm-lock.yaml contains reference to temporary package directory, isolation failed!', + ); + } + } finally { + await restoreRegistry?.(); + } +} diff --git a/tests/e2e/tests/update/update-secure-registry.ts b/tests/e2e/tests/update/update-secure-registry.ts new file mode 100644 index 000000000000..47a9db657cd8 --- /dev/null +++ b/tests/e2e/tests/update/update-secure-registry.ts @@ -0,0 +1,39 @@ +import { exec, ng } from '../../utils/process'; +import { createNpmConfigForAuthentication } from '../../utils/registry'; +import { expectToFail } from '../../utils/utils'; +import { isPrereleaseCli } from '../../utils/project'; +import { getActivePackageManager } from '../../utils/packages'; +import assert from 'node:assert'; + +export default async function () { + // The environment variable has priority over the .npmrc + delete process.env['NPM_CONFIG_REGISTRY']; + const worksMessage = 'We analyzed your package.json'; + + const extraArgs: string[] = []; + if (isPrereleaseCli()) { + extraArgs.push('--next'); + } + + // Valid authentication token + + await createNpmConfigForAuthentication(); + const { stdout: stdout2 } = await ng('update', ...extraArgs); + if (!stdout2.includes(worksMessage)) { + throw new Error(`Expected stdout to contain "${worksMessage}"`); + } + + // Invalid authentication token + + await createNpmConfigForAuthentication(true, true); + await expectToFail(() => ng('update', ...extraArgs)); + + if (getActivePackageManager() === 'yarn') { + // When running `ng update` using yarn (`yarn ng update`), yarn will set the `npm_config_registry` env variable to `https://registry.yarnpkg.com` + // Validate the registry in the RC is used. + await createNpmConfigForAuthentication(true, true); + + const error = await expectToFail(() => exec('yarn', 'ng', 'update', ...extraArgs)); + assert.match(error.message, /not allowed to access package|status code 403/); + } +} diff --git a/tests/e2e/tests/update/update.ts b/tests/e2e/tests/update/update.ts new file mode 100644 index 000000000000..fd8663503ecc --- /dev/null +++ b/tests/e2e/tests/update/update.ts @@ -0,0 +1,94 @@ +import { appendFile } from 'node:fs/promises'; +import { createProjectFromAsset } from '../../utils/assets'; +import { expectFileMatchToExist } from '../../utils/fs'; +import { getActivePackageManager } from '../../utils/packages'; +import { ng, noSilentNg } from '../../utils/process'; +import { isPrereleaseCli, useCIChrome, useCIDefaults, getNgCLIVersion } from '../../utils/project'; +import { executeBrowserTest } from '../../utils/puppeteer'; + +export default async function () { + let restoreRegistry: (() => Promise) | undefined; + + try { + // We need to use the public registry because in the local NPM server we don't have + // older versions @angular/cli packages which would cause `npm install` during `ng update` to fail. + restoreRegistry = await createProjectFromAsset('20.0-project', true); + + // CLI project version + const cliMajorProjectVersion = 20; + + // If using npm, enable legacy peer deps mode to avoid defects in npm 7+'s peer dependency resolution + // Example error where 11.2.14 satisfies the SemVer range ^11.0.0 but still fails: + // npm ERR! Conflicting peer dependency: @angular/compiler-cli@11.2.14 + // npm ERR! node_modules/@angular/compiler-cli + // npm ERR! peer @angular/compiler-cli@"^11.0.0 || ^11.2.0-next" from @angular-devkit/build-angular@0.1102.19 + // npm ERR! node_modules/@angular-devkit/build-angular + // npm ERR! dev @angular-devkit/build-angular@"~0.1102.19" from the root project + if (getActivePackageManager() === 'npm') { + await appendFile('.npmrc', '\nlegacy-peer-deps=true'); + } + + // CLI current version. + const cliMajorVersion = getNgCLIVersion().major; + + for (let version = cliMajorProjectVersion + 1; version < cliMajorVersion; version++) { + // Run all the migrations until the current build major version - 1. + // Example: when the project is using CLI version 10 and the build CLI version is 14. + // We will run the following migrations: + // - 10 -> 11 + // - 11 -> 12 + // - 12 -> 13 + const { stdout } = await ng('update', `@angular/cli@${version}`, `@angular/core@${version}`); + if ( + !stdout.includes("Executing migrations of package '@angular/cli'") && + !stdout.includes("Optional migrations of package '@angular/cli'") + ) { + throw new Error('Update did not execute migrations for @angular/cli. OUTPUT: \n' + stdout); + } + if (!stdout.includes("Executing migrations of package '@angular/core'")) { + throw new Error('Update did not execute migrations for @angular/core. OUTPUT: \n' + stdout); + } + } + } finally { + await restoreRegistry?.(); + } + + // Update Angular current build + const extraUpdateArgs = isPrereleaseCli() ? ['--next', '--force'] : []; + + // For the latest/next release we purposely don't run `ng update @angular/core`. + + // During a major release when the branch version is bumped from `12.0.0-rc.x` to `12.0.0` there would be a period were in + // the local NPM registry `@angular/cli@latest` will point to `12.0.0`, but on the public NPM repository `@angular/core@latest` will be `11.2.x`. + + // This causes `ng update @angular/core` to fail because of mismatching peer dependencies. + + // The reason for this is because of our bumping and release strategy. When we release a major version on NPM we don't tag it + // `@latest` right away, but we wait for all teams to release their packages before doing so. While this is good because all team + // packages gets tagged with `@latest` at the same time. This is problematic for our CI, since we test against the public NPM repo and are dependent on tags. + + // NB: `ng update @angular/cli` will still cause `@angular/core` packages to be updated therefore we still test updating the core package without running the command. + + await ng('update', '@angular/cli', ...extraUpdateArgs); + + // Setup testing to use CI Chrome. + await useCIChrome('twenty-project', './'); + await useCIDefaults('twenty-project'); + + // Run CLI commands. + await ng('generate', 'component', 'my-comp'); + await ng('test', '--watch=false'); + + await executeBrowserTest({ + configuration: 'production', + expectedTitleText: 'Hello, twenty-project', + }); + await executeBrowserTest({ + configuration: 'development', + expectedTitleText: 'Hello, twenty-project', + }); + + // Verify project now creates bundles + await noSilentNg('build', '--configuration=production'); + await expectFileMatchToExist('dist/twenty-project/browser', /main-[a-zA-Z0-9]{8}\.js/); +} diff --git a/tests/e2e/tests/vite/reuse-dep-optimization-cache.ts b/tests/e2e/tests/vite/reuse-dep-optimization-cache.ts new file mode 100644 index 000000000000..07be3f2a911b --- /dev/null +++ b/tests/e2e/tests/vite/reuse-dep-optimization-cache.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert'; +import { findFreePort } from '../../utils/network'; +import { execAndWaitForOutputToMatch, killAllProcesses, ng } from '../../utils/process'; + +export default async function () { + await ng('cache', 'clean'); + await ng('cache', 'on'); + + const port = await findFreePort(); + await execAndWaitForOutputToMatch( + 'ng', + ['serve', '--port', `${port}`], + /dependencies optimized/, + // Use CI:0 to force caching + { ...process.env, DEBUG: 'vite:deps', CI: '0', NO_COLOR: 'true' }, + ); + + const response = await fetch(`http://localhost:${port}/main.js`); + + assert(response.ok, `Expected 'response.ok' to be 'true'.`); + + // Terminate the dev-server + await killAllProcesses(); + + await execAndWaitForOutputToMatch( + 'ng', + ['serve', '--port=0'], + /Hash is consistent\. Skipping/, + // Use CI:0 to force caching + { ...process.env, DEBUG: 'vite:deps', CI: '0', NO_COLOR: 'true' }, + ); +} diff --git a/tests/e2e/tests/vite/ssr-base-href.ts b/tests/e2e/tests/vite/ssr-base-href.ts new file mode 100644 index 000000000000..140f2582689a --- /dev/null +++ b/tests/e2e/tests/vite/ssr-base-href.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert'; +import { ng } from '../../utils/process'; +import { replaceInFile } from '../../utils/fs'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, updateJsonFile, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await updateJsonFile('angular.json', (json) => { + json.projects['test-project'].architect.build.options['baseHref'] = '/base'; + }); + + await replaceInFile( + 'src/server.ts', + /express\(\);/, + `express(); + + app.use('/ping', (req, res) => { + return res.json({ pong: true }); + });`, + ); + + const port = await ngServe(); + + // Angular application and bundled should be affected by baseHref + await matchResponse(`http://localhost:${port}/base`, /ng-server-context/); + await matchResponse(`http://localhost:${port}/base/main.js`, /App/); + + // Server endpoint should not be affected by baseHref + await matchResponse(`http://localhost:${port}/ping`, /pong/); +} + +async function matchResponse(url: string, match: RegExp): Promise { + const response = await fetch(url); + const text = await response.text(); + + assert.match(text, match); +} diff --git a/tests/e2e/tests/vite/ssr-default.ts b/tests/e2e/tests/vite/ssr-default.ts new file mode 100644 index 000000000000..8b64a4b30f67 --- /dev/null +++ b/tests/e2e/tests/vite/ssr-default.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert'; +import { ng } from '../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Enable caching to test real development workflow. + await ng('cache', 'clean'); + await ng('cache', 'on'); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/', /Hello,/); + await validateResponse('/unknown', /Cannot GET/, 404); + + async function validateResponse(pathname: string, match: RegExp, status = 200): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, status); + } +} diff --git a/tests/e2e/tests/vite/ssr-entry-express.ts b/tests/e2e/tests/vite/ssr-entry-express.ts new file mode 100644 index 000000000000..11cf671becbf --- /dev/null +++ b/tests/e2e/tests/vite/ssr-entry-express.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; +import { replaceInFile, writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await writeMultipleFiles({ + // Replace the template of app.ng.html as it makes it harder to debug + 'src/app/app.html': '', + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + + export const routes: Routes = [ + { path: 'home', component: Home } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + 'src/server.ts': ` + import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, createNodeRequestHandler } from '@angular/ssr/node'; + import express from 'express'; + import { join } from 'node:path'; + + export function app(): express.Express { + const server = express(); + const browserDistFolder = join(import.meta.dirname, '../browser'); + const angularNodeAppEngine = new AngularNodeAppEngine(); + + server.use('/api/{*splat}', (req, res) => { + res.json({ hello: 'foo' }) + }); + + server.use(express.static(browserDistFolder, { + maxAge: '1y', + index: 'index.html' + })); + + server.use(async(req, res, next) => { + const response = await angularNodeAppEngine.handle(req); + if (response) { + writeResponseToNodeResponse(response, res); + } else { + next(); + } + }); + + return server; + } + + const server = app(); + if (isMainModule(import.meta.url)) { + const port = process.env['PORT'] || 4000; + server.listen(port, (error) => { + if (error) { + throw error; + } + + console.log(\`Node Express server listening on http://localhost:\${port}\`); + }); + } + + export const reqHandler = createNodeRequestHandler(server); + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /home works/); + + // Modify the home component and validate the change. + await modifyFileAndWaitUntilUpdated( + 'src/app/home/home.html', + 'home works', + 'yay home works!!!', + true, + ); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /yay home works/); + + // Modify the API response and validate the change. + await modifyFileAndWaitUntilUpdated('src/server.ts', `{ hello: 'foo' }`, `{ hello: 'bar' }`); + await validateResponse('/api/test', /bar/); + await validateResponse('/home', /yay home works/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} + +async function modifyFileAndWaitUntilUpdated( + filePath: string, + searchValue: string, + replaceValue: string, + hmr = false, +): Promise { + await Promise.all([ + waitForAnyProcessOutputToMatch( + hmr ? /Component update sent to client/ : /Page reload sent to client/, + ), + setTimeout(100).then(() => replaceInFile(filePath, searchValue, replaceValue)), + ]); +} diff --git a/tests/e2e/tests/vite/ssr-entry-fastify.ts b/tests/e2e/tests/vite/ssr-entry-fastify.ts new file mode 100644 index 000000000000..c50e8c2200f6 --- /dev/null +++ b/tests/e2e/tests/vite/ssr-entry-fastify.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; +import { replaceInFile, writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { installPackage, installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + await installPackage('fastify@5'); + + await writeMultipleFiles({ + // Replace the template of app.ng.html as it makes it harder to debug + 'src/app/app.html': '', + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + + export const routes: Routes = [ + { path: 'home', component: Home } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + 'src/server.ts': ` + import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, createNodeRequestHandler } from '@angular/ssr/node'; + import fastify from 'fastify'; + + export function app() { + const server = fastify(); + const angularNodeAppEngine = new AngularNodeAppEngine(); + server.get('/api/*', (req, reply) => reply.send({ hello: 'foo' })); + server.get('*', async (req, reply) => { + try { + const response = await angularNodeAppEngine.handle(req.raw); + if (response) { + await writeResponseToNodeResponse(response, reply.raw); + } else { + reply.callNotFound(); + } + } catch (error) { + reply.send(error); + } + }); + + return server; + } + + const server = app(); + if (isMainModule(import.meta.url)) { + const port = +(process.env['PORT'] || 4000); + server.listen({ port }, () => { + console.log(\`Fastify server listening on http://localhost:\${port}\`); + }); + } + + export const reqHandler = createNodeRequestHandler(async (req, res) => { + await server.ready(); + server.server.emit('request', req, res); + }); + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /home works/); + + // Modify the home component and validate the change. + await modifyFileAndWaitUntilUpdated( + 'src/app/home/home.html', + 'home works', + 'yay home works!!!', + true, + ); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /yay home works/); + + // Modify the API response and validate the change. + await modifyFileAndWaitUntilUpdated('src/server.ts', `{ hello: 'foo' }`, `{ hello: 'bar' }`); + await validateResponse('/api/test', /bar/); + await validateResponse('/home', /yay home works/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} + +async function modifyFileAndWaitUntilUpdated( + filePath: string, + searchValue: string, + replaceValue: string, + hmr = false, +): Promise { + await Promise.all([ + waitForAnyProcessOutputToMatch( + hmr ? /Component update sent to client/ : /Page reload sent to client/, + ), + setTimeout(100).then(() => replaceInFile(filePath, searchValue, replaceValue)), + ]); +} diff --git a/tests/e2e/tests/vite/ssr-entry-h3.ts b/tests/e2e/tests/vite/ssr-entry-h3.ts new file mode 100644 index 000000000000..4a4f91066000 --- /dev/null +++ b/tests/e2e/tests/vite/ssr-entry-h3.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; +import { replaceInFile, writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { installPackage, installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + await installPackage('h3@1'); + + await writeMultipleFiles({ + // Replace the template of app.ng.html as it makes it harder to debug + 'src/app/app.html': '', + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + + export const routes: Routes = [ + { path: 'home', component: Home } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + 'src/server.ts': ` + import { AngularAppEngine, createRequestHandler } from '@angular/ssr'; + import { createApp, createRouter, toWebHandler, defineEventHandler, toWebRequest } from 'h3'; + + export function app() { + const server = createApp(); + const router = createRouter(); + const angularAppEngine = new AngularAppEngine(); + + router.use( + '/api/**', + defineEventHandler(() => ({ hello: 'foo' })), + ); + + router.use( + '/**', + defineEventHandler((event) => angularAppEngine.handle(toWebRequest(event))), + ); + + server.use(router); + + return server; + } + + const server = app(); + + export const reqHandler = createRequestHandler(toWebHandler(server)); + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /home works/); + + // Modify the home component and validate the change. + await modifyFileAndWaitUntilUpdated( + 'src/app/home/home.html', + 'home works', + 'yay home works!!!', + true, + ); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /yay home works/); + + // Modify the API response and validate the change. + await modifyFileAndWaitUntilUpdated('src/server.ts', `{ hello: 'foo' }`, `{ hello: 'bar' }`); + await validateResponse('/api/test', /bar/); + await validateResponse('/home', /yay home works/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} + +async function modifyFileAndWaitUntilUpdated( + filePath: string, + searchValue: string, + replaceValue: string, + hmr = false, +): Promise { + await Promise.all([ + waitForAnyProcessOutputToMatch( + hmr ? /Component update sent to client/ : /Page reload sent to client/, + ), + setTimeout(100).then(() => replaceInFile(filePath, searchValue, replaceValue)), + ]); +} diff --git a/tests/e2e/tests/vite/ssr-entry-hono.ts b/tests/e2e/tests/vite/ssr-entry-hono.ts new file mode 100644 index 000000000000..0b0b8220fe6b --- /dev/null +++ b/tests/e2e/tests/vite/ssr-entry-hono.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; +import { replaceInFile, writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg, waitForAnyProcessOutputToMatch } from '../../utils/process'; +import { installPackage, installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + await installPackage('hono@4'); + + await writeMultipleFiles({ + // Replace the template of app.ng.html as it makes it harder to debug + 'src/app/app.html': '', + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + + export const routes: Routes = [ + { path: 'home', component: Home } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + 'src/server.ts': ` + import { AngularAppEngine, createRequestHandler } from '@angular/ssr'; + import { Hono } from 'hono'; + + export function app() { + const server = new Hono(); + const angularAppEngine = new AngularAppEngine(); + + server.get('/api/*', (c) => c.json({ hello: 'foo' })); + server.get('/*', async (c) => { + const res = await angularAppEngine.handle(c.req.raw); + return res || undefined + }); + + return server; + } + + const server = app(); + export const reqHandler = createRequestHandler(server.fetch); + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe(); + + // Verify the server is running and the API response is correct. + await validateResponse('/main.js', /bootstrapApplication/); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /home works/); + + // Modify the home component and validate the change. + await modifyFileAndWaitUntilUpdated( + 'src/app/home/home.html', + 'home works', + 'yay home works!!!', + true, + ); + await validateResponse('/api/test', /foo/); + await validateResponse('/home', /yay home works/); + + // Modify the API response and validate the change. + await modifyFileAndWaitUntilUpdated('src/server.ts', `{ hello: 'foo' }`, `{ hello: 'bar' }`); + await validateResponse('/api/test', /bar/); + await validateResponse('/home', /yay home works/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} + +async function modifyFileAndWaitUntilUpdated( + filePath: string, + searchValue: string, + replaceValue: string, + hmr = false, +): Promise { + await Promise.all([ + waitForAnyProcessOutputToMatch( + hmr ? /Component update sent to client/ : /Page reload sent to client/, + ), + setTimeout(100).then(() => replaceInFile(filePath, searchValue, replaceValue)), + ]); +} diff --git a/tests/e2e/tests/vite/ssr-error-stack.ts b/tests/e2e/tests/vite/ssr-error-stack.ts new file mode 100644 index 000000000000..8fce78b9e7ad --- /dev/null +++ b/tests/e2e/tests/vite/ssr-error-stack.ts @@ -0,0 +1,34 @@ +import { doesNotMatch, match } from 'node:assert'; +import { ng } from '../../utils/process'; +import { appendToFile } from '../../utils/fs'; +import { ngServe, useSha } from '../../utils/project'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; + +export default async function () { + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + // Create Error. + await appendToFile( + 'src/app/app.ts', + ` + (() => { + throw new Error('something happened!'); + })(); + `, + ); + + const port = await ngServe(); + const response = await fetch(`http://localhost:${port}/`); + const text = await response.text(); + + // The error is also sent in the browser, so we don't need to scrap the stderr. + match( + text, + /something happened.+at eval \(.+[\\/]+e2e-test[\\/]+test-project[\\/]+src[\\/]+app[\\/]+app\.ts:\d+:\d+\)/, + ); + doesNotMatch(text, /vite-root/); +} diff --git a/tests/e2e/tests/vite/ssr-new-dep-optimization.ts b/tests/e2e/tests/vite/ssr-new-dep-optimization.ts new file mode 100644 index 000000000000..291e8df20170 --- /dev/null +++ b/tests/e2e/tests/vite/ssr-new-dep-optimization.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert'; +import { + execAndWaitForOutputToMatch, + ng, + waitForAnyProcessOutputToMatch, +} from '../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; +import { readFile, writeFile } from '../../utils/fs'; +import { findFreePort } from '../../utils/network'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Enable caching to test real development workflow. + await ng('cache', 'clean'); + await ng('cache', 'on'); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + const port = await findFreePort(); + await execAndWaitForOutputToMatch( + 'ng', + ['serve', '--port', port.toString()], + /Application bundle generation complete/, + { ...process.env, CI: '0', NO_COLOR: 'true' }, + ); + await validateResponse('/', /Hello,/); + + await Promise.all([ + waitForAnyProcessOutputToMatch( + /optimized: @angular\/platform-browser\/animations\/async/, + 6000, + ), + writeFile( + 'src/app/app.config.ts', + ` + import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; + ${(await readFile('src/app/app.config.ts')).replace('provideRouter(routes),', 'provideAnimationsAsync(), provideRouter(routes),')} + `, + ), + ]); + + // Verify the app still works. + await validateResponse('/', /Hello,/); + + async function validateResponse(pathname: string, match: RegExp): Promise { + const response = await fetch(new URL(pathname, `http://localhost:${port}`)); + const text = await response.text(); + assert.match(text, match); + } +} diff --git a/tests/e2e/tests/vite/ssr-with-ssl.ts b/tests/e2e/tests/vite/ssr-with-ssl.ts new file mode 100644 index 000000000000..e07d01b06fcc --- /dev/null +++ b/tests/e2e/tests/vite/ssr-with-ssl.ts @@ -0,0 +1,71 @@ +import { Agent, fetch } from 'undici'; +import assert from 'node:assert'; +import { writeMultipleFiles } from '../../utils/fs'; +import { ng, silentNg } from '../../utils/process'; +import { installWorkspacePackages, uninstallPackage } from '../../utils/packages'; +import { ngServe, useSha } from '../../utils/project'; +import { getGlobalVariable } from '../../utils/env'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + // Forcibly remove in case another test doesn't clean itself up. + await uninstallPackage('@angular/ssr'); + await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install'); + await useSha(); + await installWorkspacePackages(); + + await writeMultipleFiles({ + // Replace the template of app.ng.html as it makes it harder to debug + 'src/app/app.html': '', + 'src/app/app.routes.ts': ` + import { Routes } from '@angular/router'; + import { Home } from './home/home'; + + export const routes: Routes = [ + { path: 'home', component: Home } + ]; + `, + 'src/app/app.routes.server.ts': ` + import { RenderMode, ServerRoute } from '@angular/ssr'; + + export const serverRoutes: ServerRoute[] = [ + { path: '**', renderMode: RenderMode.Server } + ]; + `, + }); + + await silentNg('generate', 'component', 'home'); + + const port = await ngServe('--ssl'); + + // http 2 + await validateResponse('/main.js', /bootstrapApplication/, true); + await validateResponse('/home', /home works/, true); + + // http 1.1 + await validateResponse('/main.js', /bootstrapApplication/, false); + await validateResponse('/home', /home works/, false); + + async function validateResponse( + pathname: string, + match: RegExp, + allowH2: boolean, + ): Promise { + const response = await fetch(new URL(pathname, `https://localhost:${port}`), { + dispatcher: new Agent({ + connect: { + allowH2, + rejectUnauthorized: false, + }, + }), + }); + + const text = await response.text(); + assert.match(text, match); + assert.equal(response.status, 200); + } +} diff --git a/tests/e2e/tests/vite/wasi.ts b/tests/e2e/tests/vite/wasi.ts new file mode 100644 index 000000000000..b7ddafb4e82c --- /dev/null +++ b/tests/e2e/tests/vite/wasi.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert'; +import { getGlobalVariable } from '../../utils/env'; +import { ng } from '../../utils/process'; + +export default async function () { + assert( + getGlobalVariable('argv')['esbuild'], + 'This test should not be called in the Webpack suite.', + ); + + const originalForceWasi = process.env['NAPI_RS_FORCE_WASI']; + process.env['NAPI_RS_FORCE_WASI'] = 'error'; + + try { + await ng('build'); + } finally { + if (originalForceWasi === undefined) { + delete process.env['NAPI_RS_FORCE_WASI']; + } else { + process.env['NAPI_RS_FORCE_WASI'] = originalForceWasi; + } + } +} diff --git a/tests/e2e/tests/vitest/browser-coverage-istanbul.ts b/tests/e2e/tests/vitest/browser-coverage-istanbul.ts new file mode 100644 index 000000000000..66f73162d586 --- /dev/null +++ b/tests/e2e/tests/vitest/browser-coverage-istanbul.ts @@ -0,0 +1,35 @@ +import { ng } from '../../utils/process'; +import { applyVitestBuilder } from '../../utils/vitest'; +import assert from 'node:assert'; +import { expectFileToExist, readFile } from '../../utils/fs'; +import { updateJsonFile } from '../../utils/project'; + +export default async function (): Promise { + // Install ONLY Istanbul coverage package. + // This will trigger the auto-detection logic to use Istanbul even for Node tests. + await applyVitestBuilder({ + coverageIstanbul: true, + }); + + // Use the 'json' reporter to get a machine-readable output for assertions. + await updateJsonFile('angular.json', (json) => { + const project = Object.values(json['projects'])[0] as any; + const test = project['architect']['test']; + test.options = { + coverageReporters: ['json', 'text'], + }; + }); + + // Run tests with coverage (defaults to Node/jsdom environment) + const { stdout } = await ng('test', '--no-watch', '--coverage'); + + // Verify that tests passed + assert.match(stdout, /1 passed/, 'Expected tests to run successfully.'); + + // Verify that coverage files are generated + const coverageJsonPath = 'coverage/test-project/coverage-final.json'; + await expectFileToExist(coverageJsonPath); + + const coverageSummary = JSON.parse(await readFile(coverageJsonPath)); + assert.ok(Object.keys(coverageSummary).length > 0, 'Expected coverage report to not be empty.'); +} diff --git a/tests/e2e/tests/vitest/browser-coverage-sourcemaps.ts b/tests/e2e/tests/vitest/browser-coverage-sourcemaps.ts new file mode 100644 index 000000000000..476c15c41d38 --- /dev/null +++ b/tests/e2e/tests/vitest/browser-coverage-sourcemaps.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; +import { expectFileToExist, readFile } from '../../utils/fs'; + +export default async function (): Promise { + await applyVitestBuilder({ + playwright: true, + coverageV8: true, + }); + + // Run tests with coverage in browser mode. + // We use the default passing tests generated for the project. + const { stdout } = await ng( + 'test', + '--no-watch', + '--browsers', + 'chromiumHeadless', + '--coverage', + '--coverage-reporters=json-summary', + ); + + // Verify that tests passed + assert.match(stdout, /pass/, 'Expected tests to run successfully.'); + + // Verify that coverage files are generated + const summaryPath = 'coverage/test-project/coverage-summary.json'; + await expectFileToExist(summaryPath); + + const summary = JSON.parse(await readFile(summaryPath)); + + // Find the key for app.ts (it might be an absolute path) + const appFileKey = Object.keys(summary).find((key) => key.endsWith('app.ts')); + assert.ok(appFileKey, 'Expected coverage summary to contain app.ts.'); + + const appCoverage = summary[appFileKey]; + assert.ok(appCoverage.lines.pct > 0, 'Expected lines percentage to be greater than 0.'); + + // Also verify that spec files are NOT present in the summary + const specFileKey = Object.keys(summary).find((key) => key.endsWith('.spec.ts')); + assert.ok(!specFileKey, 'Expected coverage report to not contain .spec.ts files.'); +} diff --git a/tests/e2e/tests/vitest/browser-coverage-validation.ts b/tests/e2e/tests/vitest/browser-coverage-validation.ts new file mode 100644 index 000000000000..66342746a91a --- /dev/null +++ b/tests/e2e/tests/vitest/browser-coverage-validation.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { execAndCaptureError } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { stripVTControlCharacters } from 'node:util'; +import { unlink } from 'node:fs/promises'; + +export default async function (): Promise { + await applyVitestBuilder({ + playwright: true, + }); + + // === Case 1: Browser configured via CLI option === + const error1 = await execAndCaptureError('ng', [ + 'test', + '--no-watch', + '--coverage', + '--browsers', + 'firefox', + ]); + const output1 = stripVTControlCharacters(error1.message); + assert.match( + output1, + /Code coverage requires either "@vitest\/coverage-v8" or "@vitest\/coverage-istanbul" to be installed./, + 'Expected validation error for missing coverage packages.', + ); + + await applyVitestBuilder({ + playwright: true, + coverageV8: true, + }); + + const configPath = 'vitest.config.mts'; + const absoluteConfigPath = path.resolve(configPath); + + try { + // === Case 2: Browser configured via vitest.config.mts (name) === + await writeFile( + configPath, + ` + import { defineConfig } from 'vitest/config'; + import { playwright } from '@vitest/browser-playwright'; + + export default defineConfig({ + test: { + coverage: { provider: 'v8' }, + browser: { + enabled: true, + name: 'firefox', + provider: playwright(), + }, + }, + }); + `, + ); + + const error2 = await execAndCaptureError('ng', [ + 'test', + '--no-watch', + '--coverage', + `--runner-config=${absoluteConfigPath}`, + ]); + const output2 = stripVTControlCharacters(error2.message); + assert.match( + output2, + /Code coverage is enabled, but the following configured browsers do not support the V8 coverage provider: firefox/, + 'Expected validation error for unsupported browser with coverage (config name).', + ); + assert.match( + output2, + /Please use the "istanbul" coverage provider/, + 'Expected the error to suggest the istanbul coverage provider.', + ); + + // === Case 3: Browser configured via vitest.config.mts (instances) === + await writeFile( + configPath, + ` + import { defineConfig } from 'vitest/config'; + import { playwright } from '@vitest/browser-playwright'; + + export default defineConfig({ + test: { + coverage: { provider: 'v8' }, + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: 'firefox' }], + } as any, + }, + }); + `, + ); + + const error3 = await execAndCaptureError('ng', [ + 'test', + '--no-watch', + '--coverage', + `--runner-config=${absoluteConfigPath}`, + ]); + const output3 = stripVTControlCharacters(error3.message); + assert.match( + output3, + /Code coverage is enabled, but the following configured browsers do not support the V8 coverage provider: firefox/, + 'Expected validation error for unsupported browser with coverage (config instances).', + ); + } finally { + // Clean up the config file so it doesn't affect other tests + try { + await unlink(configPath); + } catch {} + } +} diff --git a/tests/e2e/tests/vitest/browser-custom-config-vitest-v4.ts b/tests/e2e/tests/vitest/browser-custom-config-vitest-v4.ts new file mode 100644 index 000000000000..7865830f3b82 --- /dev/null +++ b/tests/e2e/tests/vitest/browser-custom-config-vitest-v4.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { installWorkspacePackages } from '../../utils/packages'; +import { updateJsonFile } from '../../utils/project'; + +export default async function (): Promise { + await applyVitestBuilder({ + skipInstall: true, + }); + + await updateJsonFile('package.json', (json) => { + json.devDependencies = { + ...json.devDependencies, + '@vitest/browser-playwright': '^4', + 'vitest': '^4', + 'playwright': '^1', + }; + }); + + await installWorkspacePackages(); + + await ng('generate', 'component', 'my-comp'); + + // Create vitest-base.config.mts + await writeFile( + 'vitest-base.config.mts', + ` + import { defineConfig } from 'vitest/config'; + import { playwright } from '@vitest/browser-playwright'; + + export default defineConfig({ + test: { + browser: { + enabled: true, + provider: playwright({launchOptions: {executablePath: process.env['CHROME_BIN']}}), + instances: [ + { browser: 'chromium' }, + ], + headless: true, + }, + }, + }); + `, + ); + + // Create a spec file that asserts browser environment + await writeFile( + 'src/browser.spec.ts', + ` + describe('Browser Environment Check', () => { + it('should be running in Chromium', () => { + const ua = navigator.userAgent; + // Fail the test if it's not Chrome/Chromium + if (!ua.includes('Chrome') && !ua.includes('Chromium')) { + throw new Error('Expected to be running in Chrome/Chromium, but User Agent is: ' + ua); + } + }); + }); + `, + ); + + const { stdout } = await ng('test', '--no-watch', '--runner-config'); + + assert.match(stdout, /3 passed/, 'Expected 3 tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/browser-custom-config.ts b/tests/e2e/tests/vitest/browser-custom-config.ts new file mode 100644 index 000000000000..ab9092d038af --- /dev/null +++ b/tests/e2e/tests/vitest/browser-custom-config.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; + +export default async function (): Promise { + await applyVitestBuilder({ + playwright: true, + }); + + await ng('generate', 'component', 'my-comp'); + + // Create vitest-base.config.mts + await writeFile( + 'vitest-base.config.mts', + ` + import { defineConfig } from 'vitest/config'; + import { playwright } from '@vitest/browser-playwright'; + + export default defineConfig({ + test: { + browser: { + enabled: true, + provider: playwright({launchOptions: {executablePath: process.env['CHROME_BIN']}}), + instances: [ + { browser: 'chromium' }, + ], + headless: true, + }, + }, + }); + `, + ); + + // Create a spec file that asserts browser environment + await writeFile( + 'src/browser.spec.ts', + ` + describe('Browser Environment Check', () => { + it('should be running in Chromium', () => { + const ua = navigator.userAgent; + // Fail the test if it's not Chrome/Chromium + if (!ua.includes('Chrome') && !ua.includes('Chromium')) { + throw new Error('Expected to be running in Chrome/Chromium, but User Agent is: ' + ua); + } + }); + }); + `, + ); + + const { stdout } = await ng('test', '--no-watch', '--runner-config'); + + assert.match(stdout, /3 passed/, 'Expected 3 tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/browser-no-globals.ts b/tests/e2e/tests/vitest/browser-no-globals.ts new file mode 100644 index 000000000000..3a4fcde4b96c --- /dev/null +++ b/tests/e2e/tests/vitest/browser-no-globals.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { applyVitestBuilder } from '../../utils/vitest'; + +/** + * Allow `vitest` import in browser mode. + * @see https://github.com/angular/angular-cli/issues/31745 + */ +export default async function (): Promise { + await applyVitestBuilder({ + playwright: true, + }); + + await writeFile( + 'src/app/app.spec.ts', + ` + import { test, expect } from 'vitest'; + + test('should pass', () => { + expect(true).toBe(true); + }); + `, + ); + + const { stdout } = await ng('test', '--browsers', 'ChromiumHeadless'); + + assert.match(stdout, /1 passed/, 'Expected 1 tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/browser-playwright.ts b/tests/e2e/tests/vitest/browser-playwright.ts new file mode 100644 index 000000000000..54d744e10208 --- /dev/null +++ b/tests/e2e/tests/vitest/browser-playwright.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { updateJsonFile } from '../../utils/project'; + +export default async function (): Promise { + await applyVitestBuilder({ + playwright: true, + }); + await ng('generate', 'component', 'my-comp'); + + await writeFile( + 'src/setup1.ts', + ` + import { getTestBed } from '@angular/core/testing'; + + getTestBed().configureTestingModule({}); + `, + ); + + await updateJsonFile('tsconfig.spec.json', (json) => { + json.include = [...(json.include || []), 'src/setup1.ts']; + }); + + const { stdout } = await ng( + 'test', + '--no-watch', + '--browsers', + 'chromiumHeadless', + '--setup-files', + 'src/setup1.ts', + ); + + assert.match(stdout, /2 passed/, 'Expected 2 tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/browser-sourcemaps.ts b/tests/e2e/tests/vitest/browser-sourcemaps.ts new file mode 100644 index 000000000000..4bc025a7d2b9 --- /dev/null +++ b/tests/e2e/tests/vitest/browser-sourcemaps.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng, noSilentNg } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { stripVTControlCharacters } from 'node:util'; + +export default async function (): Promise { + await applyVitestBuilder({ + playwright: true, + }); + + await ng('generate', 'component', 'my-comp'); + + // Add a failing test to verify source map support + await writeFile( + 'src/app/failing.spec.ts', + ` + describe('Failing Test', () => { + it('should fail', () => { + expect(true).toBe(false); + }); + }); + `, + ); + + try { + await noSilentNg('test', '--no-watch', '--browsers', 'chromiumHeadless'); + throw new Error('Expected "ng test" to fail.'); + } catch (error: any) { + const stdout = stripVTControlCharacters(error.stdout || error.message); + // We expect the failure from failing.spec.ts + assert.match(stdout, /1 failed/, 'Expected 1 test to fail.'); + // Check that the stack trace points to the correct file + assert.match( + stdout, + /\bsrc[\/\\]app[\/\\]failing\.spec\.ts:4:\d+/, + 'Expected stack trace to point to the source file.', + ); + } +} diff --git a/tests/e2e/tests/vitest/component.ts b/tests/e2e/tests/vitest/component.ts new file mode 100644 index 000000000000..421587892196 --- /dev/null +++ b/tests/e2e/tests/vitest/component.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; + +export default async function (): Promise { + await applyVitestBuilder(); + await ng('generate', 'component', 'my-comp'); + + const { stdout } = await ng('test'); + + assert.match(stdout, /2 passed/, 'Expected 2 tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/include.ts b/tests/e2e/tests/vitest/include.ts new file mode 100644 index 000000000000..4585194ef3c2 --- /dev/null +++ b/tests/e2e/tests/vitest/include.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; +import path from 'node:path'; + +export default async function (): Promise { + await applyVitestBuilder(); + + const { stdout: stdout1 } = await ng('test', '--include', path.resolve('src/app/app.spec.ts')); + assert.match(stdout1, /1 passed/, 'Expected 1 test to pass with absolute include.'); + + const { stdout: stdout2 } = await ng('test', '--include', path.normalize('src/app/app.spec.ts')); + assert.match(stdout2, /1 passed/, 'Expected 1 test to pass with relative include.'); +} diff --git a/tests/e2e/tests/vitest/larger-project-coverage.ts b/tests/e2e/tests/vitest/larger-project-coverage.ts new file mode 100644 index 000000000000..d5fa94c0b82c --- /dev/null +++ b/tests/e2e/tests/vitest/larger-project-coverage.ts @@ -0,0 +1,108 @@ +import { ng } from '../../utils/process'; +import { applyVitestBuilder } from '../../utils/vitest'; +import assert from 'node:assert'; +import { updateJsonFile } from '../../utils/project'; +import { readFile } from '../../utils/fs'; + +export default async function () { + await applyVitestBuilder({ + coverageV8: true, + playwright: true, + }); + + // Add coverage and threshold configuration to ensure coverage is calculated. + // Use the 'json' reporter to get a machine-readable output for assertions. + await updateJsonFile('angular.json', (json) => { + const project = Object.values(json['projects'])[0] as any; + const test = project['architect']['test']; + test.options = { + coverageReporters: ['json', 'text'], + coverageThresholds: { + // The generated component/service/pipe files are basic + // A threshold of 75 should be safe. + statements: 75, + }, + }; + }); + + const artifactCount = 100; + const initialTestCount = 1; + const generatedFiles = await generateArtifactsInBatches(artifactCount); + + const totalTests = initialTestCount + artifactCount; + const expectedMessage = new RegExp(`${totalTests} passed`); + const coverageJsonPath = 'coverage/test-project/coverage-final.json'; + + // Run tests in default (JSDOM) mode with coverage + const { stdout: jsdomStdout } = await ng('test', '--no-watch', '--coverage'); + assert.match(jsdomStdout, expectedMessage, `Expected ${totalTests} tests to pass in JSDOM mode.`); + + // Assert that every generated file is in the coverage report by reading the JSON output. + const jsdomSummary = JSON.parse(await readFile(coverageJsonPath)); + const jsdomSummaryKeys = Object.keys(jsdomSummary); + for (const file of generatedFiles) { + const found = jsdomSummaryKeys.some((key) => key.endsWith(file)); + assert.ok(found, `Expected ${file} to be in the JSDOM coverage report.`); + } + + // Run tests in browser mode with coverage + const { stdout: browserStdout } = await ng( + 'test', + '--no-watch', + '--coverage', + '--browsers', + 'ChromiumHeadless', + ); + assert.match( + browserStdout, + expectedMessage, + `Expected ${totalTests} tests to pass in browser mode.`, + ); + + // Assert that every generated file is in the coverage report for browser mode. + const browserSummary = JSON.parse(await readFile(coverageJsonPath)); + const browserSummaryKeys = Object.keys(browserSummary); + for (const file of generatedFiles) { + const found = browserSummaryKeys.some((key) => key.endsWith(file)); + assert.ok(found, `Expected ${file} to be in the browser coverage report.`); + } +} + +async function generateArtifactsInBatches(artifactCount: number): Promise { + const BATCH_SIZE = 5; + const generatedFiles: string[] = []; + let commands: Promise[] = []; + + for (let i = 0; i < artifactCount; i++) { + const type = i % 3; + const name = `test-artifact-${i}`; + + let generateType: string; + let fileSuffix: string; + + switch (type) { + case 0: + generateType = 'component'; + fileSuffix = '.ts'; + break; + case 1: + generateType = 'service'; + fileSuffix = '.ts'; + break; + default: + generateType = 'pipe'; + fileSuffix = '-pipe.ts'; + break; + } + + commands.push(ng('generate', generateType, name, '--skip-tests=false')); + generatedFiles.push(`${name}${fileSuffix}`); + + if (commands.length === BATCH_SIZE || i === artifactCount - 1) { + await Promise.all(commands); + commands = []; + } + } + + return generatedFiles; +} diff --git a/tests/e2e/tests/vitest/larger-project.ts b/tests/e2e/tests/vitest/larger-project.ts new file mode 100644 index 000000000000..9b9db25bb067 --- /dev/null +++ b/tests/e2e/tests/vitest/larger-project.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { ng } from '../../utils/process'; +import { applyVitestBuilder } from '../../utils/vitest'; + +export default async function () { + await applyVitestBuilder({ + playwright: true, + }); + + const artifactCount = 500; + // A new project starts with 1 test file (app.spec.ts) + // Each generated artifact will add one more test file. + const initialTestCount = 1; + + await generateArtifactsInBatches(artifactCount); + + const totalTests = initialTestCount + artifactCount; + const expectedMessage = new RegExp(`${totalTests} passed`); + + // Run tests in default (JSDOM) mode + const { stdout: jsdomStdout } = await ng('test', '--no-watch'); + assert.match(jsdomStdout, expectedMessage, `Expected ${totalTests} tests to pass in JSDOM mode.`); + + // Run tests in browser mode + const { stdout: browserStdout } = await ng( + 'test', + '--no-watch', + '--browsers', + 'ChromiumHeadless', + ); + assert.match( + browserStdout, + expectedMessage, + `Expected ${totalTests} tests to pass in browser mode.`, + ); +} + +async function generateArtifactsInBatches(artifactCount: number): Promise { + const files: { [path: string]: string } = {}; + + for (let i = 0; i < artifactCount; i++) { + const type = i % 3; + const name = `test-artifact-${i}`; + + switch (type) { + case 0: + files[`src/app/${name}/${name}.ts`] = ` +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-${name}', + template: '', +}) +export class TestArtifact${i}Component {} +`; + files[`src/app/${name}/${name}.spec.ts`] = ` +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestArtifact${i}Component } from './${name}'; + +describe('TestArtifact${i}Component', () => { + let component: TestArtifact${i}Component; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestArtifact${i}Component], + }).compileComponents(); + + fixture = TestBed.createComponent(TestArtifact${i}Component); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); +`; + break; + case 1: + files[`src/app/${name}.ts`] = ` +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class TestArtifact${i}Service {} +`; + files[`src/app/${name}.spec.ts`] = ` +import { TestBed } from '@angular/core/testing'; +import { TestArtifact${i}Service } from './${name}'; + +describe('TestArtifact${i}Service', () => { + let service: TestArtifact${i}Service; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(TestArtifact${i}Service); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); +`; + break; + default: + files[`src/app/${name}-pipe.ts`] = ` +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'testArtifact${i}', +}) +export class TestArtifact${i}Pipe implements PipeTransform { + transform(value: unknown): unknown { + return null; + } +} +`; + files[`src/app/${name}-pipe.spec.ts`] = ` +import { TestArtifact${i}Pipe } from './${name}-pipe'; + +describe('TestArtifact${i}Pipe', () => { + it('create an instance', () => { + const pipe = new TestArtifact${i}Pipe(); + expect(pipe).toBeTruthy(); + }); +}); +`; + break; + } + } + + const entries = Object.entries(files); + const CONCURRENCY_LIMIT = 100; + for (let i = 0; i < entries.length; i += CONCURRENCY_LIMIT) { + const chunk = entries.slice(i, i + CONCURRENCY_LIMIT); + await Promise.all( + chunk.map(async ([filePath, content]) => { + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, content.trim()); + }), + ); + } +} diff --git a/tests/e2e/tests/vitest/library.ts b/tests/e2e/tests/vitest/library.ts new file mode 100644 index 000000000000..0bb989057279 --- /dev/null +++ b/tests/e2e/tests/vitest/library.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { updateJsonFile } from '../../utils/project'; +import { ng } from '../../utils/process'; +import { appendToFile, createDir, writeFile } from '../../utils/fs'; + +export default async function (): Promise { + // Generate a library + await ng('generate', 'library', 'my-lib', '--test-runner', 'vitest'); + + // Setup Style Include Paths test + // 1. Create a shared SCSS file + await createDir('projects/my-lib/src/styles'); + await writeFile('projects/my-lib/src/styles/_vars.scss', '$primary-color: red;'); + + // 2. Update ng-package.json to include the styles directory + await updateJsonFile('projects/my-lib/ng-package.json', (json) => { + json['lib'] = { + ...json['lib'], + styleIncludePaths: ['./src/styles'], + }; + }); + + // 3. Update the component to use SCSS and import the shared file + // Rename CSS to SCSS + await ng('generate', 'component', 'styled-comp', '--project=my-lib', '--style=scss'); + + await appendToFile( + 'projects/my-lib/src/lib/styled-comp/styled-comp.scss', + ` + @use 'vars'; + p { color: vars.$primary-color; } + `, + ); + + // Run the library tests + const { stdout } = await ng('test', 'my-lib'); + + // Expect tests to pass + assert.match(stdout, /passed/, 'Expected library tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/node-sourcemaps.ts b/tests/e2e/tests/vitest/node-sourcemaps.ts new file mode 100644 index 000000000000..8e317b2b72cf --- /dev/null +++ b/tests/e2e/tests/vitest/node-sourcemaps.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng, noSilentNg } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { stripVTControlCharacters } from 'node:util'; + +export default async function (): Promise { + await applyVitestBuilder(); + await ng('generate', 'component', 'my-comp'); + + // Add a failing test to verify source map support + await writeFile( + 'src/app/failing.spec.ts', + ` + describe('Failing Test', () => { + it('should fail', () => { + expect(true).toBe(false); + }); + }); + `, + ); + + try { + await noSilentNg('test', '--no-watch'); + throw new Error('Expected "ng test" to fail.'); + } catch (error: any) { + const stdout = stripVTControlCharacters(error.stdout || error.message); + // We expect the failure from failing.spec.ts + assert.match(stdout, /1 failed/, 'Expected 1 test to fail.'); + // Check that the stack trace points to the correct file + assert.match( + stdout, + /\bsrc[\/\\]app[\/\\]failing\.spec\.ts:4:\d+/, + 'Expected stack trace to point to the source file.', + ); + } +} diff --git a/tests/e2e/tests/vitest/runner-config-path.ts b/tests/e2e/tests/vitest/runner-config-path.ts new file mode 100644 index 000000000000..456469b2d6a4 --- /dev/null +++ b/tests/e2e/tests/vitest/runner-config-path.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { writeMultipleFiles } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { applyVitestBuilder } from '../../utils/vitest'; + +export default async function (): Promise { + await applyVitestBuilder(); + + // Create a custom Vitest configuration file. + const customConfigPath = 'vitest.custom.mjs'; + await writeMultipleFiles({ + [customConfigPath]: ` + import { defineConfig } from 'vitest/config'; + export default defineConfig({ + test: { + // A unique option to confirm this file is being used. + passWithNoTests: true, + }, + }); + `, + }); + + const absoluteConfigPath = path.resolve(customConfigPath); + const { stdout } = await ng('test', `--runner-config=${absoluteConfigPath}`); + + // Assert that the CLI logs the use of the specified configuration file. + assert.match( + stdout, + /vitest\.custom\.mjs/, + 'Expected a message confirming the use of the custom config file.', + ); +} diff --git a/tests/e2e/tests/vitest/snapshot.ts b/tests/e2e/tests/vitest/snapshot.ts new file mode 100644 index 000000000000..f099ba6f8d30 --- /dev/null +++ b/tests/e2e/tests/vitest/snapshot.ts @@ -0,0 +1,79 @@ +import { ng } from '../../utils/process'; +import { replaceInFile, readFile, writeFile } from '../../utils/fs'; +import { applyVitestBuilder } from '../../utils/vitest'; +import assert from 'node:assert/strict'; +import { stripVTControlCharacters } from 'node:util'; + +export default async function () { + // Set up the test project to use the vitest runner + await applyVitestBuilder(); + + // Add snapshot assertions to the test file + await replaceInFile( + 'src/app/app.spec.ts', + `describe('App', () => {`, + ` +describe('App', () => { + it('should match file snapshot', () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + expect((app as any).title()).toMatchSnapshot(); + }); + + it('should match inline snapshot', () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + expect((app as any).title()).toMatchInlineSnapshot(); + }); +`, + ); + + // Synchronize line endings for Vitest which currently may miscalculate line counts + // with mixed file line endings. + let content = await readFile('src/app/app.spec.ts'); + content = content.replace(/\r\n/g, '\n'); + content = content.replace(/\r/g, '\n'); + await writeFile('src/app/app.spec.ts', content); + + // First run: create snapshots + const { stdout: firstRunStdout } = await ng('test'); + assert.match( + stripVTControlCharacters(firstRunStdout), + /Snapshots\s+2 written/, + 'Snapshots were not written on the first run.', + ); + + const specContent = await readFile('src/app/app.spec.ts'); + assert.match( + specContent, + /toMatchInlineSnapshot\(`"test-project"`\)/, + 'Inline snapshot was not written to the spec file.', + ); + + const snapshotContent = await readFile('src/app/__snapshots__/app.spec.ts.snap'); + assert.match( + snapshotContent, + /exports\[`App > should match file snapshot 1`\] = `"test-project"`;/, + 'File snapshot was not written to disk.', + ); + + // Second run: tests should pass with existing snapshots + await ng('test'); + + // Modify component to break snapshots + await replaceInFile('src/app/app.ts', 'test-project', 'Snapshot is broken!'); + + // Third run: tests should fail with snapshot mismatch + await assert.rejects( + () => ng('test'), + (err: any) => { + assert.match( + stripVTControlCharacters(err.toString()), + /Snapshots\s+2 failed/, + 'Expected snapshot mismatch error, but a different error occurred.', + ); + return true; + }, + 'Snapshot mismatch did not cause the test to fail.', + ); +} diff --git a/tests/e2e/tests/vitest/tslib-resolution.ts b/tests/e2e/tests/vitest/tslib-resolution.ts new file mode 100644 index 000000000000..091f5105ba7f --- /dev/null +++ b/tests/e2e/tests/vitest/tslib-resolution.ts @@ -0,0 +1,70 @@ +import { writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { applyVitestBuilder } from '../../utils/vitest'; +import assert from 'node:assert'; + +export default async function () { + await applyVitestBuilder({ + playwright: true, + }); + + // Add a custom decorator to trigger tslib usage + await writeFile( + 'src/app/custom-decorator.ts', + ` + export function MyDecorator() { + return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + // do nothing + }; + } + `, + ); + + // Add a service that uses the decorator + await writeFile( + 'src/app/test.service.ts', + ` + import { Injectable } from '@angular/core'; + import { MyDecorator } from './custom-decorator'; + + @Injectable({ + providedIn: 'root' + }) + export class TestService { + @MyDecorator() + myMethod() { + return true; + } + } + `, + ); + + // Add a test for the service + await writeFile( + 'src/app/test.service.spec.ts', + ` + import { TestBed } from '@angular/core/testing'; + import { TestService } from './test.service'; + + describe('TestService', () => { + let service: TestService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(TestService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('myMethod should return true', () => { + expect(service.myMethod()).toBe(true); + }); + }); + `, + ); + + const { stdout } = await ng('test', '--no-watch', '--browsers', 'chromiumHeadless'); + assert.match(stdout, /2 passed/, 'Expected 2 tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/unix-browser-webdriverio.ts b/tests/e2e/tests/vitest/unix-browser-webdriverio.ts new file mode 100644 index 000000000000..231f5fca4041 --- /dev/null +++ b/tests/e2e/tests/vitest/unix-browser-webdriverio.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; +import { writeFile } from '../../utils/fs'; +import { updateJsonFile } from '../../utils/project'; + +export default async function (): Promise { + await applyVitestBuilder({ + webdriver: true, + }); + + await ng('generate', 'component', 'my-comp'); + + await writeFile( + 'src/setup1.ts', + ` + import { getTestBed } from '@angular/core/testing'; + + getTestBed().configureTestingModule({}); + `, + ); + + await updateJsonFile('tsconfig.spec.json', (json) => { + json.include = [...(json.include || []), 'src/setup1.ts']; + }); + + const { stdout } = await ng( + 'test', + '--no-watch', + '--browsers', + 'chromeHeadless', + '--setup-files', + 'src/setup1.ts', + ); + + assert.match(stdout, /2 passed/, 'Expected 2 tests to pass.'); +} diff --git a/tests/e2e/tests/vitest/windows-subst-drive.ts b/tests/e2e/tests/vitest/windows-subst-drive.ts new file mode 100644 index 000000000000..3106ff846b21 --- /dev/null +++ b/tests/e2e/tests/vitest/windows-subst-drive.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { ng, silentExec } from '../../utils/process'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { stripVTControlCharacters } from 'node:util'; + +export default async function (): Promise { + // This test uses `subst` to map the project directory to a virtual drive letter + // to simulate running tests from a non-C drive on Windows. + if (process.platform !== 'win32') { + return; + } + + await applyVitestBuilder(); + + const originalCwd = process.cwd(); + const driveLetter = 'X:'; // Pick a drive letter that is unlikely to be in use. + + try { + // 1. Map the parent directory of the project to the virtual drive. + // This avoids running the project from the root of the drive (X:\), which can cause + // issues with workspace detection. + const projectParentDir = path.dirname(originalCwd); + const projectName = path.basename(originalCwd); + + await silentExec('subst', driveLetter, projectParentDir); + + // 2. Change the current process's working directory to the project folder on the virtual drive. + const newCwd = path.join(driveLetter + '\\', projectName); + process.chdir(newCwd); + + // Verify that the file system mapping is working as expected. + assert(fs.existsSync('angular.json'), 'angular.json should exist on the subst drive'); + + // 3. Run `ng test`. + const { stdout } = await ng('test', '--watch=false'); + const output = stripVTControlCharacters(stdout); + + // 4. Verify that Vitest found the test file and identified the tests within it. + assert.match( + output, + /src\/app\/app\.spec\.ts \(2 tests/, + 'Expected tests to be discovered and loaded, even if execution fails due to subst aliasing.', + ); + } finally { + // 5. Teardown: Restore CWD and remove the virtual drive mapping. + try { + process.chdir(originalCwd); + } catch (e) { + console.error('Failed to restore CWD:', e); + } + + try { + await silentExec('subst', driveLetter, '/d'); + } catch (e) { + // Ignore errors if the drive wasn't mounted or if unmount fails (best effort) + console.error(`Failed to unmount ${driveLetter}:`, e); + } + } +} diff --git a/tests/e2e/utils/BUILD.bazel b/tests/e2e/utils/BUILD.bazel new file mode 100644 index 000000000000..dbbda05702cd --- /dev/null +++ b/tests/e2e/utils/BUILD.bazel @@ -0,0 +1,24 @@ +load("//tools:defaults.bzl", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "utils", + testonly = True, + srcs = glob(["**/*.ts"]), + data = [ + "//tests/e2e/ng-snapshot", + ], + deps = [ + "//:node_modules/@types/jasmine", + "//:node_modules/@types/node", + "//:node_modules/@types/semver", + "//:node_modules/fast-glob", + "//:node_modules/puppeteer", + "//:node_modules/semver", + "//:node_modules/verdaccio", + "//:node_modules/verdaccio-auth-memory", + "//tests:node_modules/@types/tar-stream", + "//tests:node_modules/tar-stream", + ], +) diff --git a/tests/e2e/utils/assets.ts b/tests/e2e/utils/assets.ts new file mode 100644 index 000000000000..153fdaccc6de --- /dev/null +++ b/tests/e2e/utils/assets.ts @@ -0,0 +1,69 @@ +import { join } from 'node:path'; +import { chmod } from 'node:fs/promises'; +import glob from 'fast-glob'; +import { getGlobalVariable } from './env'; +import { resolve } from 'node:path'; +import { copyFile } from './fs'; +import { installWorkspacePackages, setRegistry } from './packages'; +import { getTestProjectDir, useBuiltPackagesVersions } from './project'; + +export function assetDir(assetName: string) { + return join(__dirname, '../e2e/assets', assetName); +} + +export function copyProjectAsset(assetName: string, to?: string) { + const tempRoot = join(getGlobalVariable('projects-root'), 'test-project'); + const sourcePath = assetDir(assetName); + const targetPath = join(tempRoot, to || assetName); + + return copyFile(sourcePath, targetPath); +} + +export function copyAssets(assetName: string, to?: string) { + const seed = +Date.now(); + const tempRoot = join(getTestAssetsDir(), assetName + '-' + seed); + const root = assetDir(assetName); + + return Promise.resolve() + .then(() => { + const allFiles = glob.sync('**/*', { dot: true, cwd: root }); + + return allFiles.reduce((promise, filePath) => { + const toPath = + to !== undefined ? resolve(getTestProjectDir(), to, filePath) : join(tempRoot, filePath); + + return promise + .then(() => copyFile(join(root, filePath), toPath)) + .then(() => chmod(toPath, 0o777)); + }, Promise.resolve()); + }) + .then(() => tempRoot); +} + +/** + * @returns a method that once called will restore the environment + * to use the local NPM registry. + * */ +export async function createProjectFromAsset( + assetName: string, + useNpmPackages = false, + skipInstall = false, +): Promise<() => Promise> { + const dir = await copyAssets(assetName); + process.chdir(dir); + + await setRegistry(!useNpmPackages /** useTestRegistry */); + + if (!useNpmPackages) { + await useBuiltPackagesVersions(); + } + if (!skipInstall) { + await installWorkspacePackages(); + } + + return () => setRegistry(true /** useTestRegistry */); +} + +export function getTestAssetsDir(): string { + return join(getGlobalVariable('projects-root'), 'assets'); +} diff --git a/tests/e2e/utils/env.ts b/tests/e2e/utils/env.ts new file mode 100644 index 000000000000..d2f0feece0a7 --- /dev/null +++ b/tests/e2e/utils/env.ts @@ -0,0 +1,26 @@ +const ENV_PREFIX = 'LEGACY_CLI__'; + +export function setGlobalVariable(name: string, value: any) { + if (value === undefined) { + delete process.env[ENV_PREFIX + name]; + } else { + process.env[ENV_PREFIX + name] = JSON.stringify(value); + } +} + +export function getGlobalVariable(name: string): T { + const value = process.env[ENV_PREFIX + name]; + if (value === undefined) { + throw new Error(`Trying to access variable "${name}" but it's not defined.`); + } + return JSON.parse(value) as T; +} + +export function getGlobalVariablesEnv(): NodeJS.ProcessEnv { + return Object.keys(process.env) + .filter((v) => v.startsWith(ENV_PREFIX)) + .reduce((vars, n) => { + vars[n] = process.env[n]; + return vars; + }, {}); +} diff --git a/tests/e2e/utils/fs.ts b/tests/e2e/utils/fs.ts new file mode 100644 index 000000000000..2ebf7af9d28a --- /dev/null +++ b/tests/e2e/utils/fs.ts @@ -0,0 +1,134 @@ +import { promises as fs, constants } from 'node:fs'; +import { dirname, join } from 'node:path'; + +export function readFile(fileName: string): Promise { + return fs.readFile(fileName, 'utf-8'); +} + +export function writeFile(fileName: string, content: string, options?: any): Promise { + return fs.writeFile(fileName, content, options); +} + +export function deleteFile(path: string): Promise { + return fs.unlink(path); +} + +export function rimraf(path: string): Promise { + return fs.rm(path, { + force: true, + recursive: true, + maxRetries: 3, + }); +} + +export function moveFile(from: string, to: string): Promise { + return fs.rename(from, to); +} + +export function symlinkFile(from: string, to: string, type?: string): Promise { + return fs.symlink(from, to, type); +} + +export function createDir(path: string): Promise { + return fs.mkdir(path, { recursive: true }); +} + +export async function copyFile(from: string, to: string): Promise { + await createDir(dirname(to)); + + return fs.copyFile(from, to, constants.COPYFILE_FICLONE); +} + +export async function moveDirectory(from: string, to: string): Promise { + await rimraf(to); + await createDir(to); + + for (const entry of await fs.readdir(from)) { + const fromEntry = join(from, entry); + const toEntry = join(to, entry); + if ((await fs.stat(fromEntry)).isFile()) { + await copyFile(fromEntry, toEntry); + } else { + await moveDirectory(fromEntry, toEntry); + } + } +} + +export function writeMultipleFiles(fs: { [path: string]: string }) { + return Promise.all(Object.keys(fs).map((fileName) => writeFile(fileName, fs[fileName]))); +} + +export function replaceInFile(filePath: string, match: RegExp | string, replacement: string) { + return readFile(filePath).then((content: string) => + writeFile(filePath, content.replace(match, replacement)), + ); +} + +export function appendToFile(filePath: string, text: string, options?: any) { + return readFile(filePath).then((content: string) => + writeFile(filePath, content.concat(text), options), + ); +} + +export function prependToFile(filePath: string, text: string, options?: any) { + return readFile(filePath).then((content: string) => + writeFile(filePath, text.concat(content), options), + ); +} + +export async function expectFileMatchToExist(dir: string, regex: RegExp): Promise { + const files = await fs.readdir(dir); + const fileName = files.find((name) => regex.test(name)); + + if (!fileName) { + throw new Error(`File ${regex} was expected to exist but not found...`); + } + + return fileName; +} + +export async function expectFileNotToExist(fileName: string): Promise { + try { + await fs.access(fileName, constants.F_OK); + } catch { + return; + } + + throw new Error(`File ${fileName} was expected not to exist but found...`); +} + +export async function expectFileToExist(fileName: string): Promise { + try { + await fs.access(fileName, constants.F_OK); + } catch { + throw new Error(`File ${fileName} was expected to exist but not found...`); + } +} + +export async function expectFileToMatch(fileName: string, regEx: RegExp | string): Promise { + const content = await readFile(fileName); + + const found = typeof regEx === 'string' ? content.includes(regEx) : content.match(regEx); + + if (!found) { + throw new Error( + `File "${fileName}" did not contain "${regEx}"...\nContent:\n${content}\n------`, + ); + } +} + +export async function getFileSize(fileName: string) { + const stats = await fs.stat(fileName); + + return stats.size; +} + +export async function expectFileSizeToBeUnder(fileName: string, sizeInBytes: number) { + const fileSize = await getFileSize(fileName); + + if (fileSize > sizeInBytes) { + throw new Error( + `File "${fileName}" exceeded file size of "${sizeInBytes}". Size is ${fileSize}.`, + ); + } +} diff --git a/tests/e2e/utils/git.ts b/tests/e2e/utils/git.ts new file mode 100644 index 000000000000..39bb47ce7d52 --- /dev/null +++ b/tests/e2e/utils/git.ts @@ -0,0 +1,21 @@ +import { git, silentGit } from './process'; + +export async function gitClean(): Promise { + await silentGit('clean', '-df'); + await silentGit('reset', '--hard'); +} + +export async function expectGitToBeClean(): Promise { + const { stdout } = await silentGit('status', '--porcelain'); + if (stdout != '') { + throw new Error('Git repo is not clean...\n' + stdout); + } +} + +export async function gitCommit(message: string): Promise { + await git('add', '-A'); + const { stdout } = await silentGit('status', '--porcelain'); + if (stdout != '') { + await git('commit', '-am', message); + } +} diff --git a/tests/e2e/utils/network.ts b/tests/e2e/utils/network.ts new file mode 100644 index 000000000000..48602f0000d2 --- /dev/null +++ b/tests/e2e/utils/network.ts @@ -0,0 +1,30 @@ +import { createServer } from 'node:net'; + +/** + * Finds an available network port on the loopback interface (127.0.0.1). + * This is useful for tests that need to bind to a free port to avoid conflicts. + * Explicitly binds to IPv4 localhost to avoid firewall prompts, IPv6 binding issues, and ensure consistency. + * + * @returns A promise that resolves with an available port number. + */ +export function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.once('listening', () => { + const address = srv.address(); + if (!address || typeof address === 'string') { + // Should not happen with TCP, but good for type safety + srv.close(() => reject(new Error('Failed to get server address'))); + return; + } + const port = address.port; + srv.close((e) => (e ? reject(e) : resolve(port))); + }); + + // If an error happens (e.g. during bind), the server is not listening, + // so we should not call close(). + srv.once('error', (e) => reject(e)); + // Explicitly listen on IPv4 localhost to avoid firewall prompts, IPv6 binding issues, and ensure consistency. + srv.listen(0, '127.0.0.1'); + }); +} diff --git a/tests/e2e/utils/packages.ts b/tests/e2e/utils/packages.ts new file mode 100644 index 000000000000..087d771f14b8 --- /dev/null +++ b/tests/e2e/utils/packages.ts @@ -0,0 +1,79 @@ +import { getGlobalVariable } from './env'; +import { ProcessOutput, silentBun, silentNpm, silentPnpm, silentYarn } from './process'; + +export interface PkgInfo { + readonly name: string; + readonly version: string; + readonly path: string; +} + +export function getActivePackageManager(): 'npm' | 'yarn' | 'bun' | 'pnpm' { + return getGlobalVariable('package-manager'); +} + +export async function installWorkspacePackages(options?: { force?: boolean }): Promise { + switch (getActivePackageManager()) { + case 'npm': + const npmArgs = ['install']; + if (options?.force) { + npmArgs.push('--force'); + } + await silentNpm(...npmArgs); + break; + case 'yarn': + await silentYarn('install'); + break; + case 'pnpm': + await silentPnpm('install'); + break; + case 'bun': + await silentBun('install'); + break; + } +} + +export function installPackage(specifier: string, registry?: string): Promise { + const registryOption = registry ? [`--registry=${registry}`] : []; + switch (getActivePackageManager()) { + case 'npm': + return silentNpm('install', specifier, ...registryOption); + case 'yarn': + return silentYarn('add', specifier, ...registryOption); + case 'bun': + return silentBun('add', specifier, ...registryOption); + case 'pnpm': + return silentPnpm('add', specifier, ...registryOption); + } +} + +export async function uninstallPackage(name: string): Promise { + try { + switch (getActivePackageManager()) { + case 'npm': + await silentNpm('uninstall', name); + break; + case 'yarn': + await silentYarn('remove', name); + break; + case 'bun': + await silentBun('remove', name); + break; + case 'pnpm': + await silentPnpm('remove', name); + break; + } + } catch (e) { + // Yarn throws an error when trying to remove a package that is not installed. + console.error(e); + } +} + +export async function setRegistry(useTestRegistry: boolean): Promise { + const url = useTestRegistry + ? getGlobalVariable('package-registry') + : 'https://registry.npmjs.org'; + + // Ensure local test registry is used when outside a project + // Yarn supports both `NPM_CONFIG_REGISTRY` and `YARN_REGISTRY`. + process.env['NPM_CONFIG_REGISTRY'] = url; +} diff --git a/tests/e2e/utils/process.ts b/tests/e2e/utils/process.ts new file mode 100644 index 000000000000..021536af1688 --- /dev/null +++ b/tests/e2e/utils/process.ts @@ -0,0 +1,493 @@ +import { spawn, SpawnOptions } from 'node:child_process'; +import * as child_process from 'node:child_process'; +import { getGlobalVariable, getGlobalVariablesEnv } from './env'; +import { delimiter, join, resolve } from 'node:path'; +import { stripVTControlCharacters, styleText } from 'node:util'; +import { assertIsError } from './utils'; + +interface ExecOptions { + silent?: boolean; + waitForMatch?: RegExp; + env?: NodeJS.ProcessEnv; + stdin?: string; + cwd?: string; +} + +/** + * While `NPM_CONFIG_` and `YARN_` are case insensitive we filter based on case. + * This is because when invoking a command using `yarn` it will add a bunch of these variables in lower case. + * This causes problems when we try to update the variables during the test setup. + */ +const NPM_CONFIG_RE = /^(NPM_CONFIG_|YARN_|NO_UPDATE_NOTIFIER)/; + +let _processes: child_process.ChildProcess[] = []; + +export type ProcessOutput = { + stdout: string; + stderr: string; +}; + +function _exec(options: ExecOptions, cmd: string, args: string[]): Promise { + const cwd = options.cwd ?? process.cwd(); + const env = options.env ?? process.env; + + console.log( + `==========================================================================================`, + ); + + // Ensure the custom npm and yarn global bin is on the PATH + // https://docs.npmjs.com/cli/v8/configuring-npm/folders#executables + const paths = [ + join(getGlobalVariable('yarn-global'), 'bin'), + join(getGlobalVariable('npm-global'), process.platform.startsWith('win') ? '' : 'bin'), + env.PATH || process.env['PATH'], + ].join(delimiter); + + args = args.filter((x) => x !== undefined); + const flags = [ + options.silent && 'silent', + options.waitForMatch && `matching(${options.waitForMatch})`, + ] + .filter((x) => !!x) // Remove false and undefined. + .join(', ') + .replace(/^(.+)$/, ' [$1]'); // Proper formatting. + + console.log( + styleText(['blue'], `Running \`${cmd} ${args.map((x) => `"${x}"`).join(' ')}\`${flags}...`), + ); + console.log(styleText(['blue'], `CWD: ${cwd}`)); + + const spawnOptions: SpawnOptions = { + cwd, + env: { ...env, PATH: paths }, + }; + + if (process.platform.startsWith('win')) { + args.unshift('/c', cmd); + cmd = 'cmd.exe'; + spawnOptions['stdio'] = 'pipe'; + } + + const childProcess = child_process.spawn(cmd, args, spawnOptions); + + _processes.push(childProcess); + + // Create the error here so the stack shows who called this function. + const error = new Error(); + + const processPromise = new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + let matched = false; + + // Return log info about the current process status + function envDump() { + return `STDOUT:\n${stdout}\n\nSTDERR:\n${stderr}`; + } + + childProcess.stdout!.on('data', (data: Buffer) => { + stdout += data.toString('utf-8'); + + if (options.waitForMatch && stdout.match(options.waitForMatch)) { + resolve({ stdout, stderr }); + matched = true; + } + + if (options.silent) { + return; + } + + data + .toString('utf-8') + .split(/[\n\r]+/) + .filter((line) => line !== '') + .forEach((line) => console.log(' ' + line)); + }); + + childProcess.stderr!.on('data', (data: Buffer) => { + stderr += data.toString('utf-8'); + + if (options.waitForMatch && stderr.match(options.waitForMatch)) { + resolve({ stdout, stderr }); + matched = true; + } + + if (options.silent) { + return; + } + + data + .toString('utf-8') + .split(/[\n\r]+/) + .filter((line) => line !== '') + .forEach((line) => console.error(styleText(['yellow'], ' ' + line))); + }); + + childProcess.on('close', (code) => { + _processes = _processes.filter((p) => p !== childProcess); + + if (options.waitForMatch && !matched) { + reject( + `Process output didn't match - "${cmd} ${args.join(' ')}": '${ + options.waitForMatch + }': ${code}...\n\n${envDump()}\n`, + ); + return; + } + + if (!code) { + resolve({ stdout, stderr }); + return; + } + + reject(`Process exit error - "${cmd} ${args.join(' ')}": ${code}...\n\n${envDump()}\n`); + }); + + childProcess.on('error', (err) => { + reject(`Process error - "${cmd} ${args.join(' ')}": ${err}...\n\n${envDump()}\n`); + }); + + // Provide input to stdin if given. + if (options.stdin) { + childProcess.stdin!.write(options.stdin); + childProcess.stdin!.end(); + } + }).catch((err) => { + error.message = err.toString(); + return Promise.reject(error); + }); + + if (!options.waitForMatch) { + return processPromise; + } + + let timeout: NodeJS.Timeout | undefined; + const timeoutPromise: Promise = new Promise((_resolve, reject) => { + // Wait for 60 seconds and timeout. + const duration = 60_000; + timeout = setTimeout(() => { + reject( + new Error(`Waiting for ${options.waitForMatch} timed out (timeout: ${duration}msec)...`), + ); + }, duration); + }); + + return Promise.race([timeoutPromise, processPromise]).finally( + () => timeout && clearTimeout(timeout), + ); +} + +export function extractNpmEnv() { + return Object.keys(process.env) + .filter((v) => NPM_CONFIG_RE.test(v)) + .reduce((vars, n) => { + vars[n] = process.env[n]; + return vars; + }, {}); +} + +export function extractCIAndInfraEnv(): NodeJS.ProcessEnv { + return Object.keys(process.env) + .filter( + (v) => + v.startsWith('SAUCE_') || + v === 'CI' || + v === 'CHROME_BIN' || + v === 'CHROME_PATH' || + v === 'CHROMEDRIVER_BIN' || + v.startsWith('JS_BINARY__'), + ) + .reduce((vars, n) => { + vars[n] = process.env[n]; + return vars; + }, {}); +} + +function extractNgEnv() { + return Object.keys(process.env) + .filter((v) => v.startsWith('NG_')) + .reduce((vars, n) => { + vars[n] = process.env[n]; + return vars; + }, {}); +} + +export async function waitForAnyProcessOutputToMatch( + match: RegExp, + timeout = 30000, +): Promise { + let timeoutId: ReturnType | null = null; + + // Race between _all_ processes, and the timeout. First one to resolve/reject wins. + const timeoutPromise: Promise = new Promise((_resolve, reject) => { + // Wait for 30 seconds and timeout. + timeoutId = setTimeout(() => { + reject(new Error(`Waiting for ${match} timed out (timeout: ${timeout}msec)...`)); + }, timeout); + }); + + const matchPromises: Promise[] = _processes.map( + (childProcess) => + new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + + childProcess.stdout!.on('data', (data: Buffer) => { + stdout += data.toString(); + if (stripVTControlCharacters(stdout).match(match)) { + resolve({ stdout, stderr }); + } + }); + + childProcess.stderr!.on('data', (data: Buffer) => { + stderr += data.toString(); + if (stripVTControlCharacters(stderr).match(match)) { + resolve({ stdout, stderr }); + } + }); + }), + ); + + const matchingProcess = await Promise.race(matchPromises.concat([timeoutPromise])); + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + return matchingProcess; +} + +/** + * Kills a process by PID + * @param pid The PID of the process to kill + * @param signal The signal to send to the process + */ +async function killProcess(pid: number, signal: NodeJS.Signals): Promise { + if (process.platform === 'win32') { + // /T kills child processes, /F forces it + await new Promise((resolve) => { + child_process.exec(`taskkill /pid ${pid} /T /F`, () => resolve()); + }); + } else { + // Use -pid to signal the entire process group + try { + process.kill(-pid, signal); + } catch (error) { + assertIsError(error); + if (error.code !== 'ESRCH') { + throw error; + } + } + } +} + +/** + * Kills all tracked processes + */ +export async function killAllProcesses(signal: NodeJS.Signals = 'SIGTERM'): Promise { + const processesToKill: Promise[] = []; + + while (_processes.length) { + const childProc = _processes.pop(); + if (!childProc || childProc.pid === undefined || childProc.killed) { + continue; + } + + processesToKill.push(killProcess(childProc.pid, signal)); + } + + await Promise.all(processesToKill); +} + +export function exec(cmd: string, ...args: string[]) { + return _exec({}, cmd, args); +} + +export function silentExec(cmd: string, ...args: string[]) { + return _exec({ silent: true }, cmd, args); +} + +export function execWithEnv(cmd: string, args: string[], env: NodeJS.ProcessEnv, stdin?: string) { + return _exec({ env, stdin }, cmd, args); +} + +export async function execAndCaptureError( + cmd: string, + args: string[], + env?: NodeJS.ProcessEnv, + stdin?: string, +): Promise { + try { + await _exec({ env, stdin }, cmd, args); + throw new Error('Tried to capture subprocess exception, but it completed successfully.'); + } catch (err) { + if (err instanceof Error) { + return err; + } + throw new Error('Subprocess exception was not an Error instance'); + } +} + +export async function execAndWaitForOutputToMatch( + cmd: string, + args: string[], + match: RegExp, + env?: NodeJS.ProcessEnv, +) { + if (cmd === 'ng' && args[0] === 'serve') { + // Accept matches up to 20 times after the initial match. + // Useful because the Webpack watcher can rebuild a few times due to files changes that + // happened just before the build (e.g. `git clean`). + // This seems to be due to host file system differences, see + // https://nodejs.org/docs/latest/api/fs.html#fs_caveats + const maxRetries = 20; + let lastResult = await _exec({ waitForMatch: match, env }, cmd, args); + + for (let i = 0; i < maxRetries; i++) { + try { + lastResult = await waitForAnyProcessOutputToMatch(match, 2500); + } catch { + // If we timeout (no new match found), we assume the process is stable. + break; + } + } + + return lastResult; + } else { + return _exec({ waitForMatch: match, env }, cmd, args); + } +} + +export function ng(...args: string[]) { + const argv = getGlobalVariable('argv'); + const maybeSilentNg = argv['nosilent'] ? noSilentNg : silentNg; + if (['build', 'serve', 'test', 'e2e', 'extract-i18n'].indexOf(args[0]) != -1) { + if (args[0] == 'e2e') { + // Wait 1 second before running any end-to-end test. + return new Promise((resolve) => setTimeout(resolve, 1000)).then(() => maybeSilentNg(...args)); + } + + return maybeSilentNg(...args); + } else { + return noSilentNg(...args); + } +} + +export function noSilentNg(...args: string[]) { + return _exec({}, 'ng', args); +} + +export function silentNg(...args: string[]) { + return _exec({ silent: true }, 'ng', args); +} + +export function silentNpm(...args: string[]): Promise; +export function silentNpm(args: string[], options?: { cwd?: string }): Promise; +export function silentNpm( + ...args: string[] | [args: string[], options?: { cwd?: string }] +): Promise { + if (Array.isArray(args[0])) { + const [params, options] = args; + return _exec( + { + silent: true, + cwd: (options as { cwd?: string } | undefined)?.cwd, + }, + 'npm', + params, + ); + } else { + return _exec({ silent: true }, 'npm', args as string[]); + } +} + +export function silentYarn(...args: string[]) { + return _exec({ silent: true }, 'yarn', args); +} + +export function silentPnpm(...args: string[]) { + return _exec({ silent: true }, 'pnpm', args); +} + +export function silentBun(...args: string[]) { + return _exec({ silent: true }, 'bun', args); +} + +export function globalNpm(args: string[], env?: NodeJS.ProcessEnv) { + if (!process.env.LEGACY_CLI_RUNNER) { + throw new Error( + 'The global npm cli should only be executed from the primary e2e runner process', + ); + } + + return _exec({ silent: true, env }, 'npm', args); +} + +export function node(...args: string[]) { + return _exec({}, process.execPath, args); +} + +export function git(...args: string[]) { + return _exec({}, process.env.GIT_BIN || 'git', args); +} + +export function silentGit(...args: string[]) { + return _exec({ silent: true }, process.env.GIT_BIN || 'git', args); +} + +/** + * Launch the given entry in an child process isolated to the test environment. + * + * The test environment includes the local NPM registry, isolated NPM globals, + * the PATH variable only referencing the local node_modules and local NPM + * registry (not the test runner or standard global node_modules). + */ +export async function launchTestProcess(entry: string, ...args: any[]): Promise { + const tempRoot: string = getGlobalVariable('tmp-root'); + + // Extract explicit environment variables for the test process. + const env: NodeJS.ProcessEnv = { + TEMP: tempRoot, + TMPDIR: tempRoot, + HOME: tempRoot, + + // Use BAZEL_TARGET as a metadata variable to show it is a + // process managed by bazel + BAZEL_TARGET: process.env.BAZEL_TARGET, + + ...extractNpmEnv(), + ...extractCIAndInfraEnv(), + ...extractNgEnv(), + ...getGlobalVariablesEnv(), + }; + + // Only include paths within the sandboxed test environment or external + // non angular-cli paths such as /usr/bin for generic commands. + env.PATH = process.env + .PATH!.split(delimiter) + .filter((p) => p.startsWith(tempRoot) || !p.includes('angular-cli')) + .join(delimiter); + + const testProcessArgs = [ + // Note: `__dirname` is the bundle directory here. + resolve(__dirname, 'e2e/utils/test_process.js'), + entry, + ...args, + ]; + + return new Promise((resolve, reject) => { + spawn(process.execPath, testProcessArgs, { + stdio: 'inherit', + env, + }) + .on('close', (code) => { + if (!code) { + resolve(); + return; + } + + reject(`Process error - "${testProcessArgs}`); + }) + .on('error', (err) => { + reject(`Process exit error - "${testProcessArgs}]\n\n${err}`); + }); + }); +} diff --git a/tests/e2e/utils/project.ts b/tests/e2e/utils/project.ts new file mode 100644 index 000000000000..0deb6ea48262 --- /dev/null +++ b/tests/e2e/utils/project.ts @@ -0,0 +1,250 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { prerelease, SemVer } from 'semver'; +import { getGlobalVariable } from './env'; +import { readFile, replaceInFile, writeFile } from './fs'; +import { gitCommit } from './git'; +import { findFreePort } from './network'; +import { installWorkspacePackages, PkgInfo } from './packages'; +import { execAndWaitForOutputToMatch, git, ng } from './process'; +import { join } from 'node:path'; + +export function updateJsonFile(filePath: string, fn: (json: any) => any | void) { + return readFile(filePath).then((tsConfigJson) => { + // Remove single and multiline comments + const tsConfig = JSON.parse(tsConfigJson.replace(/\/\*\s(.|\n|\r)*\s\*\/|\/\/.*/g, '')) as any; + const result = fn(tsConfig) || tsConfig; + + return writeFile(filePath, JSON.stringify(result, null, 2)); + }); +} + +export function updateTsConfig(fn: (json: any) => any | void) { + return updateJsonFile('tsconfig.json', fn); +} + +export async function ngServe(...args: string[]) { + const port = await findFreePort(); + + const esbuild = getGlobalVariable('argv')['esbuild']; + const validBundleRegEx = esbuild ? /complete\./ : /Compiled successfully\./; + + await execAndWaitForOutputToMatch( + 'ng', + ['serve', '--port', String(port), ...args], + validBundleRegEx, + ); + + return port; +} + +export async function prepareProjectForE2e(name: string) { + const argv: Record = getGlobalVariable('argv'); + + await git('config', 'user.email', 'angular-core+e2e@google.com'); + await git('config', 'user.name', 'Angular CLI E2E'); + await git('config', 'commit.gpgSign', 'false'); + await git('config', 'core.longpaths', 'true'); + + if (argv['ng-snapshots'] || argv['ng-tag']) { + await useSha(); + } + + console.log(`Project ${name} created... Installing packages.`); + await installWorkspacePackages(); + + await useCIChrome(name, ''); + await useCIDefaults(name); + + // Force sourcemaps to be from the root of the filesystem. + await updateJsonFile('tsconfig.json', (json) => { + json['compilerOptions']['sourceRoot'] = '/'; + }); + await gitCommit('prepare-project-for-e2e'); +} + +export function useBuiltPackagesVersions(): Promise { + const packages: { [name: string]: PkgInfo } = getGlobalVariable('package-tars'); + + return updateJsonFile('package.json', (json) => { + json['dependencies'] ??= {}; + json['devDependencies'] ??= {}; + + for (const packageName of Object.keys(packages)) { + if (packageName in json['dependencies']) { + json['dependencies'][packageName] = packages[packageName].version; + } else if (packageName in json['devDependencies']) { + json['devDependencies'][packageName] = packages[packageName].version; + } + } + }); +} + +export async function useSha(): Promise { + const argv = getGlobalVariable('argv'); + if (!argv['ng-snapshots'] && !argv['ng-tag']) { + return; + } + + // We need more than the sha here, version is also needed. Examples of latest tags: + // 7.0.0-beta.4+dd2a650 + // 6.1.6+4a8d56a + const label = argv['ng-tag'] || ''; + const ngSnapshotVersions = require('../ng-snapshot/package.json'); + + return updateJsonFile('package.json', (json) => { + // Install over the project with snapshot builds. + function replaceDependencies(key: string) { + const missingSnapshots: string[] = []; + Object.keys(json[key] || {}) + .filter((name) => name.startsWith('@angular/')) + .forEach((name) => { + const pkgName = name.split(/\//)[1]; + if (pkgName === 'cli' || pkgName === 'ssr' || pkgName === 'build') { + return; + } + + if (label) { + json[key][`@angular/${pkgName}`] = `github:angular/${pkgName}-builds${label}`; + } else { + const replacement = ngSnapshotVersions.dependencies[`@angular/${pkgName}`]; + if (!replacement) { + missingSnapshots.push(`missing @angular/${pkgName}`); + } + json[key][`@angular/${pkgName}`] = replacement; + } + }); + if (missingSnapshots.length > 0) { + throw new Error( + 'e2e test with --ng-snapshots requires all angular packages be ' + + 'listed in tests/e2e/ng-snapshot/package.json.\nErrors:\n' + + missingSnapshots.join('\n '), + ); + } + } + + replaceDependencies('dependencies'); + replaceDependencies('devDependencies'); + }); +} + +export function useCIDefaults(projectName = 'test-project'): Promise { + return updateJsonFile('angular.json', (workspaceJson) => { + // Disable progress reporting on CI to reduce spam. + const project = workspaceJson.projects[projectName]; + const appTargets = project.targets || project.architect; + appTargets.build.options.progress = false; + appTargets.test.options.progress = false; + + if (appTargets.serve) { + // Use a random port in serve. + appTargets.serve.options ??= {}; + appTargets.serve.options.port = 0; + } + }); +} + +export async function useCIChrome(projectName: string, projectDir = ''): Promise { + const karmaConf = path.join(projectDir, 'karma.conf.js'); + if (fs.existsSync(karmaConf)) { + // Ensure the headless sandboxed chrome is configured in the karma config + await replaceInFile( + karmaConf, + `browsers: ['Chrome'],`, + `browsers: ['ChromeHeadlessNoSandbox'], + customLaunchers: { + ChromeHeadlessNoSandbox: { + base: 'ChromeHeadless', + flags: ['--no-sandbox', '--headless', '--disable-gpu', '--disable-dev-shm-usage'], + }, + },`, + ); + } + + // Update to use the headless sandboxed chrome + return updateJsonFile('angular.json', (workspaceJson) => { + const project = workspaceJson.projects[projectName]; + const appTargets = project.targets || project.architect; + if (appTargets.test.builder === '@angular/build:unit-test') { + appTargets.test.options.browsers = ['ChromeHeadlessNoSandbox']; + } else { + appTargets.test.options.browsers = 'ChromeHeadlessNoSandbox'; + } + }); +} + +export function getNgCLIVersion(): SemVer { + const packages: { [name: string]: PkgInfo } = getGlobalVariable('package-tars'); + + return new SemVer(packages['@angular/cli'].version); +} + +export function isPrereleaseCli(): boolean { + return (prerelease(getNgCLIVersion())?.length ?? 0) > 0; +} + +export function updateServerFileForEsbuild(filepath: string): Promise { + return writeFile( + filepath, + ` + import { APP_BASE_HREF } from '@angular/common'; + import { CommonEngine } from '@angular/ssr/node'; + import express from 'express'; + import { join, resolve } from 'node:path'; + import bootstrap from './main.server'; + + // The Express app is exported so that it can be used by serverless Functions. + export function app(): express.Express { + const server = express(); + const serverDistFolder = import.meta.dirname; + const browserDistFolder = resolve(serverDistFolder, '../browser'); + const indexHtml = join(serverDistFolder, 'index.server.html'); + + const commonEngine = new CommonEngine(); + + server.set('view engine', 'html'); + server.set('views', browserDistFolder); + + server.use(express.static(browserDistFolder, { + maxAge: '1y', + index: false, + })); + + // All regular routes use the Angular engine + server.use((req, res, next) => { + const { protocol, originalUrl, baseUrl, headers } = req; + + commonEngine + .render({ + bootstrap, + documentFilePath: indexHtml, + url: \`\${protocol}://\${headers.host}\${originalUrl}\`, + publicPath: browserDistFolder, + providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }], + }) + .then((html) => res.send(html)) + .catch((err) => next(err)); + }); + + return server; + } + + function run(): void { + const port = process.env['PORT'] || 4000; + const server = app(); + server.listen(port, (error) => { + if (error) { + throw error; + } + console.log(\`Node Express server listening on http://localhost:\${port}\`); + }); + } + + run(); + `, + ); +} + +export function getTestProjectDir(): string { + return join(getGlobalVariable('projects-root'), 'test-project'); +} diff --git a/tests/e2e/utils/puppeteer.ts b/tests/e2e/utils/puppeteer.ts new file mode 100644 index 000000000000..ae8aae45761a --- /dev/null +++ b/tests/e2e/utils/puppeteer.ts @@ -0,0 +1,93 @@ +import { type Page, launch } from 'puppeteer'; +import { execAndWaitForOutputToMatch, killAllProcesses } from './process'; +import { stripVTControlCharacters } from 'node:util'; + +export interface BrowserTestOptions { + project?: string; + configuration?: string; + baseUrl?: string; + checkFn?: (page: Page) => Promise; + expectedTitleText?: string; +} + +export async function executeBrowserTest(options: BrowserTestOptions = {}) { + let url = options.baseUrl; + let hasStartedServer = false; + + try { + if (!url) { + // Start serving and find address (1 - Webpack; 2 - Vite) + const match = /(?:open your browser on|Local:)\s+(http:\/\/localhost:\d+\/)/; + const serveArgs = ['serve', '--port=0', '--no-watch', '--no-live-reload']; + if (options.project) { + serveArgs.push(options.project); + } + if (options.configuration) { + serveArgs.push(`--configuration=${options.configuration}`); + } + + const { stdout } = await execAndWaitForOutputToMatch('ng', serveArgs, match, { + ...process.env, + 'NO_COLOR': '1', + }); + url = stripVTControlCharacters(stdout).match(match)?.[1]; + if (!url) { + throw new Error('Could not find serving URL'); + } + hasStartedServer = true; + } + + const browser = await launch({ + executablePath: process.env['CHROME_BIN'], + headless: true, + args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], + }); + try { + const page = await browser.newPage(); + + // Capture errors + const errors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') { + errors.push(msg.text()); + } + }); + page.on('pageerror', (err) => { + const error = `${err}`; + if (!error.includes('Hot Module Replacement is disabled')) { + errors.push(error); + } + }); + + await page.goto(url); + + if (options.checkFn) { + await options.checkFn(page); + } else { + // Default check: verify h1 content and no browser errors + const expectedText = options.expectedTitleText || 'Hello, test-project'; + + // Wait for the h1 element to appear and contain the expected text + await page.waitForFunction( + (selector: string, text: string) => { + const doc = (globalThis as any).document; + return doc.querySelector(selector)?.textContent?.includes(text); + }, + { timeout: 10000 }, // Max 10 seconds wait time + 'h1', + expectedText, + ); + } + + if (errors.length > 0) { + throw new Error(`Browser console errors detected:\n${errors.join('\n')}`); + } + } finally { + await browser.close(); + } + } finally { + if (hasStartedServer) { + await killAllProcesses(); + } + } +} diff --git a/tests/e2e/utils/registry.ts b/tests/e2e/utils/registry.ts new file mode 100644 index 000000000000..5a871fb20ce6 --- /dev/null +++ b/tests/e2e/utils/registry.ts @@ -0,0 +1,124 @@ +import { ChildProcess, fork } from 'node:child_process'; +import { on } from 'node:events'; +import { mkdir } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { getGlobalVariable } from './env'; +import { writeFile, readFile } from './fs'; +import { existsSync } from 'node:fs'; + +export async function createNpmRegistry( + port: number, + httpsPort: number, + withAuthentication = false, +): Promise { + // Setup local package registry + const registryPath = join(getGlobalVariable('tmp-root'), 'registry'); + if (!existsSync(registryPath)) { + await mkdir(registryPath); + } + + const configFileName = withAuthentication ? 'verdaccio_auth.yaml' : 'verdaccio.yaml'; + let configContent = await readFile(join(__dirname, '../', configFileName)); + configContent = configContent + .replace(/\$\{HTTP_PORT\}/g, String(port)) + .replace(/\$\{HTTPS_PORT\}/g, String(httpsPort)); + const configPath = join(registryPath, configFileName); + + await writeFile(configPath, configContent); + + const verdaccioBin = join(dirname(require.resolve('verdaccio/package.json')), 'bin/verdaccio'); + const verdaccioServer = fork(verdaccioBin, ['-c', configPath]); + for await (const events of on(verdaccioServer, 'message', { + signal: AbortSignal.timeout(30_000), + })) { + if ( + events.some( + (event: unknown) => + event && + typeof event === 'object' && + 'verdaccio_started' in event && + event.verdaccio_started, + ) + ) { + break; + } + } + + return verdaccioServer; +} + +// Token was generated using `echo -n 'testing:s3cret' | openssl base64`. +const VALID_TOKEN = `dGVzdGluZzpzM2NyZXQ=`; + +export async function createNpmConfigForAuthentication( + /** + * When true, the authentication token will be scoped to the registry URL. + * @example + * ```ini + * //localhost:4876/:_auth="dGVzdGluZzpzM2NyZXQ=" + * ``` + * + * When false, the authentication will be added as seperate key. + * @example + * ```ini + * _auth="dGVzdGluZzpzM2NyZXQ="` + * ``` + */ + scopedAuthentication = true, + /** When true, an incorrect token is used. Use this to validate authentication failures. */ + invalidToken = false, +): Promise { + const token = invalidToken ? `invalid=` : VALID_TOKEN; + const registry = (getGlobalVariable('package-secure-registry') as string).replace(/^\w+:/, ''); + + await writeFile( + '.npmrc', + scopedAuthentication + ? ` +${registry}/:_auth="${token}" +${registry}/:always-auth=true +always-auth=true +registry=http:${registry} +` + : ` +_auth="${token}" +always-auth=true +registry=http:${registry} +`, + ); + + await writeFile( + '.yarnrc', + scopedAuthentication + ? ` +${registry}/:_auth "${token}" +${registry}/:always-auth true +always-auth true +registry http:${registry} +` + : ` +_auth "${token}" +always-auth true +registry http:${registry} +`, + ); +} + +export function setNpmEnvVarsForAuthentication( + /** When true, an incorrect token is used. Use this to validate authentication failures. */ + invalidToken = false, + /** When true, `YARN_REGISTRY` is used instead of `NPM_CONFIG_REGISTRY`. */ + useYarnEnvVariable = false, +): void { + delete process.env['YARN_REGISTRY']; + delete process.env['NPM_CONFIG_REGISTRY']; + + const registryKey = useYarnEnvVariable ? 'YARN_REGISTRY' : 'NPM_CONFIG_REGISTRY'; + process.env[registryKey] = getGlobalVariable('package-secure-registry'); + + process.env['NPM_CONFIG__AUTH'] = invalidToken ? `invalid=` : VALID_TOKEN; + + // Needed for verdaccio when used with yarn + // https://verdaccio.org/docs/en/cli-registry#yarn + process.env['NPM_CONFIG_ALWAYS_AUTH'] = 'true'; +} diff --git a/tests/e2e/utils/tar.ts b/tests/e2e/utils/tar.ts new file mode 100644 index 000000000000..016e88f25024 --- /dev/null +++ b/tests/e2e/utils/tar.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { createReadStream } from 'node:fs'; +import { normalize } from 'node:path'; +import { createGunzip } from 'node:zlib'; +import { extract } from 'tar-stream'; + +/** + * Extract and return the contents of a single file out of a tar file. + * + * @param tarball the tar file to extract from + * @param filePath the path of the file to extract + * @returns the Buffer of file or an error on fs/tar error or file not found + */ +export function extractFile(tarball: string, filePath: string): Promise { + const normalizedFilePath = normalize(filePath); + + return new Promise((resolve, reject) => { + const extractor = extract(); + + extractor.on('entry', (header, stream, next) => { + if (normalize(header.name) !== normalizedFilePath) { + stream.resume(); + next(); + + return; + } + + const chunks: Buffer[] = []; + stream.on('data', (chunk) => chunks.push(chunk as Buffer)); + stream.on('error', reject); + stream.on('end', () => { + resolve(Buffer.concat(chunks)); + next(); + }); + }); + + extractor.on('finish', () => reject(new Error(`'${filePath}' not found in '${tarball}'.`))); + + const readStream = createReadStream(tarball); + const gunzip = createGunzip(); + + readStream.on('error', reject); + gunzip.on('error', reject); + extractor.on('error', reject); + + readStream.pipe(gunzip).pipe(extractor as unknown as NodeJS.WritableStream); + }); +} diff --git a/tests/e2e/utils/test_process.ts b/tests/e2e/utils/test_process.ts new file mode 100644 index 000000000000..2d767185f465 --- /dev/null +++ b/tests/e2e/utils/test_process.ts @@ -0,0 +1,34 @@ +import { killAllProcesses } from './process'; + +const testScript: string = process.argv[2]; +const testModule = require(testScript); +const testFunction: () => Promise | void = + typeof testModule == 'function' + ? testModule + : typeof testModule.default == 'function' + ? testModule.default + : () => { + throw new Error('Invalid test module.'); + }; + +// 4-minute timeout to prevent hanging tests from blocking the runner indefinitely. +const TEST_TIMEOUT_MS = 4 * 60 * 1000; + +(async () => { + let timeoutId: NodeJS.Timeout | undefined; + try { + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`Test "${testScript}" timed out after 4 minutes.`)); + }, TEST_TIMEOUT_MS); + }); + + await Promise.race([testFunction(), timeoutPromise]); + } catch (e) { + console.error('Test Process error', e); + process.exitCode = -1; + } finally { + clearTimeout(timeoutId); + await killAllProcesses().finally(() => process.exit()); + } +})(); diff --git a/tests/e2e/utils/utils.ts b/tests/e2e/utils/utils.ts new file mode 100644 index 000000000000..0c255f53eb86 --- /dev/null +++ b/tests/e2e/utils/utils.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert'; +import { mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +export function expectToFail(fn: () => Promise, errorMessage?: string): Promise { + return fn().then( + () => { + const functionSource = fn.name || (fn).source || fn.toString(); + const errorDetails = errorMessage ? `\n\tDetails:\n\t${errorMessage}` : ''; + throw new Error( + `Function ${functionSource} was expected to fail, but succeeded.${errorDetails}`, + ); + }, + (err) => { + return err instanceof Error ? err : new Error(err); + }, + ); +} + +export async function mktempd(prefix: string, tempRoot?: string): Promise { + return realpath( + await mkdtemp( + path.join(tempRoot || process.env.E2E_TEMP || process.env.TEST_TMPDIR || tmpdir(), prefix), + ), + ); +} + +export async function mockHome(cb: (home: string) => Promise): Promise { + const tempHome = await mktempd('angular-cli-e2e-home-'); + + const oldHome = process.env.HOME; + process.env.HOME = tempHome; + + try { + await cb(tempHome); + } finally { + process.env.HOME = oldHome; + + await rm(tempHome, { recursive: true, force: true }); + } +} + +export function assertIsError(value: unknown): asserts value is Error & { code?: string } { + const isError = + value instanceof Error || + // The following is needing to identify errors coming from RxJs. + (typeof value === 'object' && value && 'name' in value && 'message' in value); + assert(isError, 'catch clause variable is not an Error instance'); +} diff --git a/tests/e2e/utils/version.ts b/tests/e2e/utils/version.ts new file mode 100644 index 000000000000..34dc29720b91 --- /dev/null +++ b/tests/e2e/utils/version.ts @@ -0,0 +1,13 @@ +import * as fs from 'node:fs'; +import * as semver from 'semver'; + +export function readNgVersion(): string { + const packageJson: any = JSON.parse( + fs.readFileSync('./node_modules/@angular/core/package.json', 'utf8'), + ) as { version: string }; + return packageJson['version']; +} + +export function ngVersionMatches(range: string): boolean { + return semver.satisfies(readNgVersion(), range); +} diff --git a/tests/e2e/utils/vitest.ts b/tests/e2e/utils/vitest.ts new file mode 100644 index 000000000000..ebb9d9d12298 --- /dev/null +++ b/tests/e2e/utils/vitest.ts @@ -0,0 +1,88 @@ +import { createRequire } from 'node:module'; +import { installWorkspacePackages } from './packages'; +import { updateJsonFile } from './project'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +/** Updates the `test` builder in the current workspace to use Vitest. */ +export async function applyVitestBuilder(options?: { + coverageV8?: boolean; + coverageIstanbul?: boolean; + playwright?: boolean; + webdriver?: boolean; + skipInstall?: boolean; +}): Promise { + const { coverageV8, coverageIstanbul, playwright, webdriver, skipInstall } = options ?? {}; + + const schematicsAngular = createRequire(process.cwd() + '/').resolve( + '@schematics/angular/package.json', + ); + + const latestVersionsPath = resolve(schematicsAngular, '../utility/latest-versions/package.json'); + + const { dependencies: latestVersion } = JSON.parse(await readFile(latestVersionsPath, 'utf-8')); + + await updateJsonFile('package.json', (json) => { + [ + 'karma', + 'karma-chrome-launcher', + 'karma-coverage', + 'karma-jasmine', + 'karma-jasmine-html-reporter', + 'jasmine-core', + '@types/jasmine', + 'istanbul-lib-instrument', + ].forEach((dep) => { + delete json.devDependencies[dep]; + }); + + json.devDependencies['vitest'] = latestVersion['vitest']; + json.devDependencies['jsdom'] = latestVersion['jsdom']; + + if (webdriver) { + json.devDependencies['webdriverio'] = latestVersion['webdriverio']; + json.devDependencies['@vitest/browser-webdriverio'] = + latestVersion['@vitest/browser-webdriverio']; + } + + if (playwright) { + json.devDependencies['playwright'] = latestVersion['playwright']; + json.devDependencies['@vitest/browser-playwright'] = + latestVersion['@vitest/browser-playwright']; + } + + if (coverageV8) { + json.devDependencies['@vitest/coverage-v8'] = latestVersion['@vitest/coverage-v8']; + } + + if (coverageIstanbul) { + json.devDependencies['@vitest/coverage-istanbul'] = + latestVersion['@vitest/coverage-istanbul']; + } + }); + + if (!skipInstall) { + await installWorkspacePackages(); + } + + await updateJsonFile('angular.json', (json) => { + const projects = Object.values(json['projects']); + if (projects.length !== 1) { + throw new Error( + `Expected exactly one project but found ${projects.length} projects named ${Object.keys( + json['projects'], + ).join(', ')}`, + ); + } + const project = projects[0]! as any; + + // Update to Vitest builder. + const test = project['architect']['test']; + test['builder'] = '@angular/build:unit-test'; + test['options'] = {}; + }); + + await updateJsonFile('tsconfig.spec.json', (tsconfig) => { + tsconfig['compilerOptions']['types'] = ['vitest/globals']; + }); +} diff --git a/tests/e2e_runner.ts b/tests/e2e_runner.ts new file mode 100644 index 000000000000..1fa0c294d4ee --- /dev/null +++ b/tests/e2e_runner.ts @@ -0,0 +1,424 @@ +import { parseArgs, styleText } from 'node:util'; +import glob from 'fast-glob'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { getGlobalVariable, setGlobalVariable } from './e2e/utils/env'; +import { gitClean } from './e2e/utils/git'; +import { createNpmRegistry } from './e2e/utils/registry'; +import { launchTestProcess } from './e2e/utils/process'; +import { delimiter, dirname, join } from 'node:path'; +import { findFreePort } from './e2e/utils/network'; +import { extractFile } from './e2e/utils/tar'; +import { realpathSync } from 'node:fs'; +import { PkgInfo } from './e2e/utils/packages'; +import { getTestProjectDir } from './e2e/utils/project'; +import { mktempd } from './e2e/utils/utils'; + +Error.stackTraceLimit = Infinity; + +// tslint:disable:no-global-tslint-disable no-console + +/** + * Here's a short description of those flags: + * --debug If a test fails, block the thread so the temporary directory isn't deleted. + * --noproject Skip creating a project or using one. + * --noglobal Skip linking your local @angular/cli directory. Can save a few seconds. + * --nosilent Never silence ng commands. + * --ng-tag=TAG Use a specific tag for build snapshots. Similar to ng-snapshots but point to a + * tag instead of using the latest `main`. + * --ng-snapshots Install angular snapshot builds in the test project. + * --glob Run tests matching this glob pattern (relative to tests/e2e/). + * --ignore Ignore tests matching this glob pattern. + * --nb-shards Total number of shards that this is part of. Default is 2 if --shard is + * passed in. + * --shard Index of this processes' shard. + * --package-manager Package manager to use. + * --package=path An npm package to be published before running tests + * + * If unnamed flags are passed in, the list of tests will be filtered to include only those passed. + */ +const parsed = parseArgs({ + allowPositionals: true, + options: { + 'debug': { type: 'boolean', default: !!process.env.BUILD_WORKSPACE_DIRECTORY }, + 'esbuild': { type: 'boolean' }, + 'glob': { type: 'string', default: 'tests/**/*.js' }, + 'ignore': { type: 'string', multiple: true }, + 'ng-snapshots': { type: 'boolean' }, + 'ng-tag': { type: 'string' }, + 'ng-version': { type: 'string' }, + 'noglobal': { type: 'boolean' }, + 'noproject': { type: 'boolean' }, + 'nosilent': { type: 'boolean' }, + 'package': { type: 'string', multiple: true, default: ['./dist/_*.tgz'] }, + 'package-manager': { type: 'string', default: 'npm' }, + 'verbose': { type: 'boolean' }, + + 'nb-shards': { type: 'string' }, + 'shard': { type: 'string' }, + }, +}); + +const argv = { + ...parsed.values, + _: parsed.positionals, + 'nb-shards': + parsed.values['nb-shards'] ?? + (Number(process.env.E2E_SHARD_TOTAL ?? 1) * Number(process.env.TEST_TOTAL_SHARDS ?? 1) || 1), + shard: + parsed.values.shard ?? + (process.env.E2E_SHARD_INDEX === undefined && process.env.TEST_SHARD_INDEX === undefined + ? undefined + : Number(process.env.E2E_SHARD_INDEX ?? 0) * Number(process.env.TEST_TOTAL_SHARDS ?? 1) + + Number(process.env.TEST_SHARD_INDEX ?? 0)), +}; + +// Indicate sharding support for Bazel. +if (process.env['TEST_SHARD_STATUS_FILE']) { + fs.writeFileSync(process.env['TEST_SHARD_STATUS_FILE'], '', 'utf8'); +} + +/** + * Set the error code of the process to 255. This is to ensure that if something forces node + * to exit without finishing properly, the error code will be 255. Right now that code is not used. + * + * - 1 When tests succeed we already call `process.exit(0)`, so this doesn't change any correct + * behaviour. + * + * One such case that would force node <= v6 to exit with code 0, is a Promise that doesn't resolve. + */ +process.exitCode = 255; + +/** + * Mark this process as the main e2e_runner + */ +process.env.LEGACY_CLI_RUNNER = '1'; + +/** + * Add external git toolchain onto PATH + */ +if (process.env.GIT_BIN) { + process.env.PATH = process.env.PATH! + delimiter + dirname(process.env.GIT_BIN!); +} + +/** + * Add external browser toolchains onto PATH + */ +if (process.env.CHROME_BIN) { + process.env.PATH = process.env.PATH! + delimiter + dirname(process.env.CHROME_BIN!); +} + +// Under bazel the compiled file (.js) and types (.d.ts) are available. +const SRC_FILE_EXT_RE = /\.js$/; +const testGlob = (process.env.TESTBRIDGE_TEST_ONLY ?? argv.glob).replace(/\.ts$/, '.js'); + +const e2eRoot = path.join(__dirname, 'e2e'); +const allSetups = glob.sync(`setup/**/*.js`, { cwd: e2eRoot }).sort(); +const allInitializers = glob.sync(`initialize/**/*.js`, { cwd: e2eRoot }).sort(); +const isWindows = process.platform === 'win32'; + +const allTests = glob + .sync(testGlob, { cwd: e2eRoot, ignore: argv.ignore }) + // Replace windows slashes. + .map((name) => name.replace(/\\/g, '/')) + .filter((name) => { + if (name.endsWith('/setup.js')) { + return false; + } + if (!SRC_FILE_EXT_RE.test(name)) { + return false; + } + + // The below is to exclude specific tests that are not intented to run for the current package manager. + // This is also important as without the trickery the tests that take the longest ex: update.ts (2.5mins) + // will be executed on the same shard. + const fileName = path.basename(name); + if ( + (fileName.startsWith('yarn-') && argv['package-manager'] !== 'yarn') || + (fileName.startsWith('npm-') && argv['package-manager'] !== 'npm') + ) { + return false; + } + + if (isWindows) { + if (fileName.startsWith('unix-')) { + return false; + } + } else if (fileName.startsWith('windows-')) { + return false; + } + + return true; + }) + .sort(); + +const shardId = argv['shard'] !== undefined ? Number(argv['shard']) : null; +const nbShards = shardId === null ? 1 : Number(argv['nb-shards']); +const tests = allTests.filter((name) => { + // Check for naming tests on command line. + if (argv._.length == 0) { + return true; + } + + return argv._.some((argName) => { + return ( + path.join(process.cwd(), argName + '') == path.join(__dirname, 'e2e', name) || + argName == name || + argName == name.replace(SRC_FILE_EXT_RE, '') + ); + }); +}); + +console.log(`Running with shard configuration:`); +console.log(`Total shards: ${nbShards}, current shard: ${shardId}`); + +// Remove tests that are not part of this shard. +const testsToRun = tests.filter((name, i) => shardId === null || i % nbShards == shardId); + +if (testsToRun.length === 0) { + if (shardId !== null && tests.length <= shardId) { + console.log(`No tests to run on shard ${shardId}, exiting`); + console.log(`Without sharding, there were ${tests.length} tests found.`); + process.exit(0); + } else { + console.log(`No tests would be ran, aborting.`); + process.exit(1); + } +} + +if (shardId !== null) { + console.log(`Running shard ${shardId} of ${nbShards}`); +} + +/** + * Load all the files from the e2e, filter and sort them and build a promise of their default + * export. + */ +if (testsToRun.length == allTests.length) { + console.log(`Running ${testsToRun.length} tests`); +} else { + console.log(`Running ${testsToRun.length} tests (${allTests.length} total)`); +} + +console.log(['Tests:', ...testsToRun].join('\n ')); + +setGlobalVariable('argv', argv); +setGlobalVariable('package-manager', argv['package-manager']); + +// Use the chrome supplied by bazel or the puppeteer chrome and webdriver-manager driver outside. +// This is needed by karma-chrome-launcher, protractor etc. +// https://github.com/karma-runner/karma-chrome-launcher#headless-chromium-with-puppeteer +// +// Resolve from relative paths to absolute paths within the bazel runfiles tree +// so subprocesses spawned in a different working directory can still find them. +process.env.CHROME_BIN = path.resolve(process.env.CHROME_BIN!); +process.env.CHROME_PATH = path.resolve(process.env.CHROME_PATH!); +process.env.CHROMEDRIVER_BIN = path.resolve(process.env.CHROMEDRIVER_BIN!); + +(async () => { + const tempRoot = await mktempd('angular-cli-e2e-'); + setGlobalVariable('tmp-root', tempRoot); + + process.on('SIGINT', deleteTemporaryRoot); + process.on('exit', deleteTemporaryRoot); + + const [httpPort, httpsPort, packageTars] = await Promise.all([ + findFreePort(), + findFreePort(), + findPackageTars(), + ]); + setGlobalVariable('package-registry', 'http://localhost:' + httpPort); + setGlobalVariable('package-secure-registry', 'http://localhost:' + httpsPort); + setGlobalVariable('package-tars', packageTars); + + // NPM registries for the lifetime of the test execution + const registryProcess = await createNpmRegistry(httpPort, httpPort); + const secureRegistryProcess = await createNpmRegistry(httpPort, httpsPort, true); + + try { + console.log(` Using "${tempRoot}" as temporary directory for a new project.`); + + await runSteps(runSetup, allSetups, 'setup'); + await runSteps(runInitializer, allInitializers, 'initializer'); + await runSteps(runTest, testsToRun, 'test'); + + if (shardId !== null) { + console.log(styleText(['green'], `Done shard ${shardId} of ${nbShards}.`)); + } else { + console.log(styleText(['green'], 'Done.')); + } + + process.exitCode = 0; + } catch (err) { + if (err instanceof Error) { + console.log('\n'); + console.error(styleText(['red'], err.message)); + if (err.stack) { + console.error(styleText(['red'], err.stack)); + } + } else { + console.error(styleText(['red'], String(err))); + } + + if (argv.debug) { + console.log(`Current Directory: ${process.cwd()}`); + console.log('Will loop forever while you debug... CTRL-C to quit.'); + + // Wait forever until user explicitly cancels. + await new Promise(() => {}); + } + + process.exitCode = 1; + } finally { + registryProcess.kill(); + secureRegistryProcess.kill(); + } +})().catch((err) => { + console.error(styleText(['red'], `Unkown Error: ${err}`)); + process.exitCode = 1; +}); + +async function runSteps( + run: (name: string) => Promise | void, + steps: string[], + type: 'setup' | 'test' | 'initializer', +) { + const capsType = type[0].toUpperCase() + type.slice(1); + + for (const [stepIndex, relativeName] of steps.entries()) { + // Make sure this is a windows compatible path. + let absoluteName = path.join(e2eRoot, relativeName).replace(SRC_FILE_EXT_RE, ''); + if (/^win/.test(process.platform)) { + absoluteName = absoluteName.replace(/\\/g, path.posix.sep); + } + + const name = relativeName.replace(SRC_FILE_EXT_RE, ''); + const start = Date.now(); + + printHeader(name, stepIndex, steps.length, type); + + // Run the test function with the current file. + try { + await run(absoluteName); + } catch (e) { + console.log('\n'); + console.error(styleText(['red'], `${capsType} "${name}" failed...`)); + + throw e; + } + + console.log('----'); + printFooter(name, type, start); + } +} + +function runSetup(absoluteName: string): Promise { + const module = require(absoluteName); + + return (typeof module === 'function' ? module : module.default)(); +} + +/** + * Run a file from the projects root directory in a subprocess via launchTestProcess(). + */ +function runInitializer(absoluteName: string): Promise { + process.chdir(getGlobalVariable('projects-root')); + + return launchTestProcess(absoluteName); +} + +/** + * Run a file from the main 'test-project' directory in a subprocess via launchTestProcess(). + */ +async function runTest(absoluteName: string): Promise { + process.chdir(getTestProjectDir()); + + await launchTestProcess(absoluteName); + await cleanTestProject(); +} + +async function cleanTestProject() { + await gitClean(); + + const testProject = getTestProjectDir(); + + // Note: Dist directory is not cleared between tests, as `git clean` + // doesn't delete it. + await rm(join(testProject, 'dist/'), { recursive: true, force: true }); +} + +function printHeader( + testName: string, + testIndex: number, + count: number, + type: 'setup' | 'initializer' | 'test', +) { + const text = `(${testIndex + 1} of ${count})`; + const fullIndex = testIndex * nbShards + (shardId ?? 0) + 1; + const shard = + shardId === null || type !== 'test' + ? '' + : styleText( + ['yellow'], + ` [${shardId}:${nbShards}]` + styleText(['bold'], ` (${fullIndex}/${tests.length})`), + ); + console.log( + styleText(['green'], `Running ${type} "`) + + styleText(['bold', 'blue'], testName) + + styleText(['green'], '" ') + + styleText(['bold', 'white'], text) + + shard + + styleText(['green'], '...'), + ); +} + +function printFooter(testName: string, type: 'setup' | 'initializer' | 'test', startTime: number) { + const capsType = type[0].toUpperCase() + type.slice(1); + + // Round to hundredth of a second. + const t = Math.round((Date.now() - startTime) / 10) / 100; + console.log( + styleText(['green'], `${capsType} "`) + + styleText(['bold', 'blue'], testName) + + styleText(['green'], '" took ') + + styleText(['bold', 'blue'], t.toFixed(2)) + + styleText(['green'], 's...'), + ); + console.log(''); +} + +// Collect the packages passed as arguments and return as {package-name => pkg-path} +async function findPackageTars(): Promise<{ [pkg: string]: PkgInfo }> { + const pkgs: string[] = (getGlobalVariable('argv').package as string[]).flatMap((p) => + glob.sync(p), + ); + + const pkgJsons = await Promise.all( + pkgs.map((pkg) => realpathSync(pkg)).map((pkg) => extractFile(pkg, 'npm_package/package.json')), + ); + + return pkgs.reduce( + (all, pkg, i) => { + const json = pkgJsons[i].toString('utf8'); + const { name, version } = JSON.parse(json) as { name: string; version: string }; + if (!name) { + throw new Error(`Package ${pkg} - package.json name/version not found`); + } + + all[name] = { path: realpathSync(pkg), name, version }; + return all; + }, + {} as { [pkg: string]: PkgInfo }, + ); +} + +function deleteTemporaryRoot(): void { + try { + fs.rmSync(getGlobalVariable('tmp-root'), { + recursive: true, + force: true, + maxRetries: 3, + }); + } catch {} +} diff --git a/tests/legacy-cli/e2e/assets/1.0-project/.angular-cli.json b/tests/legacy-cli/e2e/assets/1.0-project/.angular-cli.json deleted file mode 100644 index 87c52afc90b8..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/.angular-cli.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "$schema": "./node_modules/@angular/cli/lib/config/schema.json", - "project": { - "name": "one-oh-project" - }, - "apps": [ - { - "root": "src", - "outDir": "dist", - "assets": [ - "assets", - "favicon.ico" - ], - "index": "index.html", - "main": "main.ts", - "polyfills": "polyfills.ts", - "test": "test.ts", - "tsconfig": "tsconfig.app.json", - "testTsconfig": "tsconfig.spec.json", - "prefix": "app", - "styles": [ - "styles.css" - ], - "scripts": [], - "environmentSource": "environments/environment.ts", - "environments": { - "dev": "environments/environment.ts", - "prod": "environments/environment.prod.ts" - } - } - ], - "e2e": { - "protractor": { - "config": "./protractor.conf.js" - } - }, - "lint": [ - { - "project": "src/tsconfig.app.json" - }, - { - "project": "src/tsconfig.spec.json" - }, - { - "project": "e2e/tsconfig.e2e.json" - } - ], - "test": { - "karma": { - "config": "./karma.conf.js" - } - }, - "defaults": { - "styleExt": "css", - "component": {} - } -} diff --git a/tests/legacy-cli/e2e/assets/1.0-project/.editorconfig b/tests/legacy-cli/e2e/assets/1.0-project/.editorconfig deleted file mode 100644 index 6e87a003da89..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/.editorconfig +++ /dev/null @@ -1,13 +0,0 @@ -# Editor configuration, see http://editorconfig.org -root = true - -[*] -charset = utf-8 -indent_style = space -indent_size = 2 -insert_final_newline = true -trim_trailing_whitespace = true - -[*.md] -max_line_length = off -trim_trailing_whitespace = false diff --git a/tests/legacy-cli/e2e/assets/1.0-project/.gitignore b/tests/legacy-cli/e2e/assets/1.0-project/.gitignore deleted file mode 100644 index 54bfd2001e64..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/.gitignore +++ /dev/null @@ -1,42 +0,0 @@ -# See http://help.github.com/ignore-files/ for more about ignoring files. - -# compiled output -/dist -/tmp -/out-tsc - -# dependencies -/node_modules - -# IDEs and editors -/.idea -.project -.classpath -.c9/ -*.launch -.settings/ -*.sublime-workspace - -# IDE - VSCode -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json - -# misc -/.sass-cache -/connect.lock -/coverage -/libpeerconnection.log -npm-debug.log -testem.log -/typings - -# e2e -/e2e/*.js -/e2e/*.map - -# System Files -.DS_Store -Thumbs.db diff --git a/tests/legacy-cli/e2e/assets/1.0-project/README.md b/tests/legacy-cli/e2e/assets/1.0-project/README.md deleted file mode 100644 index f0369813ccea..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# OneOhProject - -This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 1.0.0. - -## Development server - -Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. - -## Code scaffolding - -Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive/pipe/service/class/module`. - -## Build - -Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `-prod` flag for a production build. - -## Running unit tests - -Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). - -## Running end-to-end tests - -Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). -Before running the tests make sure you are serving the app via `ng serve`. - -## Further help - -To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). diff --git a/tests/legacy-cli/e2e/assets/1.0-project/e2e/app.e2e-spec.ts b/tests/legacy-cli/e2e/assets/1.0-project/e2e/app.e2e-spec.ts deleted file mode 100644 index 28aa6669b387..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/e2e/app.e2e-spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { OneOhProjectPage } from './app.po'; - -describe('one-oh-project App', () => { - let page: OneOhProjectPage; - - beforeEach(() => { - page = new OneOhProjectPage(); - }); - - it('should display message saying app works', () => { - page.navigateTo(); - expect(page.getParagraphText()).toEqual('app works!'); - }); -}); diff --git a/tests/legacy-cli/e2e/assets/1.0-project/e2e/app.po.ts b/tests/legacy-cli/e2e/assets/1.0-project/e2e/app.po.ts deleted file mode 100644 index 5a8e232ac895..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/e2e/app.po.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { browser, element, by } from 'protractor'; - -export class OneOhProjectPage { - navigateTo() { - return browser.get('/'); - } - - getParagraphText() { - return element(by.css('app-root h1')).getText(); - } -} diff --git a/tests/legacy-cli/e2e/assets/1.0-project/e2e/tsconfig.e2e.json b/tests/legacy-cli/e2e/assets/1.0-project/e2e/tsconfig.e2e.json deleted file mode 100644 index ac7a37325798..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/e2e/tsconfig.e2e.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../out-tsc/e2e", - "module": "commonjs", - "target": "es5", - "types":[ - "jasmine", - "node" - ] - } -} diff --git a/tests/legacy-cli/e2e/assets/1.0-project/karma.conf.js b/tests/legacy-cli/e2e/assets/1.0-project/karma.conf.js deleted file mode 100644 index ca70e74eee47..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/karma.conf.js +++ /dev/null @@ -1,45 +0,0 @@ -/*eslint-disable*/ -// Karma configuration file, see link for more information -// https://karma-runner.github.io/0.13/config/configuration-file.html - -module.exports = function (config) { - config.set({ - basePath: '', - frameworks: ['jasmine', '@angular/cli'], - plugins: [ - require('karma-jasmine'), - require('karma-chrome-launcher'), - require('karma-jasmine-html-reporter'), - require('karma-coverage-istanbul-reporter'), - require('@angular/cli/plugins/karma') - ], - client:{ - clearContext: false // leave Jasmine Spec Runner output visible in browser - }, - files: [ - { pattern: './src/test.ts', watched: false } - ], - preprocessors: { - './src/test.ts': ['@angular/cli'] - }, - mime: { - 'text/x-typescript': ['ts','tsx'] - }, - coverageIstanbulReporter: { - reports: [ 'html', 'lcovonly' ], - fixWebpackSourcePaths: true - }, - angularCli: { - environment: 'dev' - }, - reporters: config.angularCli && config.angularCli.codeCoverage - ? ['progress', 'coverage-istanbul'] - : ['progress', 'kjhtml'], - port: 9876, - colors: true, - logLevel: config.LOG_INFO, - autoWatch: true, - browsers: ['Chrome'], - singleRun: false - }); -}; diff --git a/tests/legacy-cli/e2e/assets/1.0-project/package.json b/tests/legacy-cli/e2e/assets/1.0-project/package.json deleted file mode 100644 index 713912dc4a80..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "one-oh-project", - "version": "0.0.0", - "license": "MIT", - "scripts": { - "ng": "ng", - "start": "ng serve", - "build": "ng build", - "test": "ng test", - "lint": "ng lint", - "e2e": "ng e2e" - }, - "private": true, - "dependencies": { - "@angular/animations": "^5.2.0", - "@angular/common": "^5.2.0", - "@angular/compiler": "^5.2.0", - "@angular/core": "^5.2.0", - "@angular/forms": "^5.2.0", - "@angular/http": "^5.2.0", - "@angular/platform-browser": "^5.2.0", - "@angular/platform-browser-dynamic": "^5.2.0", - "@angular/router": "^5.2.0", - "core-js": "^2.4.1", - "rxjs": "^5.5.6", - "zone.js": "^0.8.19" - }, - "devDependencies": { - "@angular/cli": "0.0.0", - "@angular/compiler-cli": "^5.2.0", - "@types/jasmine": "2.5.38", - "@types/node": "~6.0.60", - "codelyzer": "^4.0.1", - "jasmine-core": "~2.5.2", - "jasmine-spec-reporter": "~3.2.0", - "karma": "~1.4.1", - "karma-chrome-launcher": "~2.0.0", - "karma-cli": "~1.0.1", - "karma-jasmine": "~1.1.0", - "karma-jasmine-html-reporter": "^0.2.2", - "karma-coverage-istanbul-reporter": "^0.2.0", - "protractor": "~5.1.0", - "ts-node": "~4.1.0", - "tslint": "~5.9.1", - "typescript": "~2.4.2" - } -} diff --git a/tests/legacy-cli/e2e/assets/1.0-project/protractor.conf.js b/tests/legacy-cli/e2e/assets/1.0-project/protractor.conf.js deleted file mode 100644 index c398ead76901..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/protractor.conf.js +++ /dev/null @@ -1,31 +0,0 @@ -/*eslint-disable*/ -// Protractor configuration file, see link for more information -// https://github.com/angular/protractor/blob/master/lib/config.ts - -const { SpecReporter } = require('jasmine-spec-reporter'); - -exports.config = { - allScriptsTimeout: 11000, - specs: [ - './e2e/**/*.e2e-spec.ts' - ], - capabilities: { - 'browserName': 'chrome' - }, - directConnect: true, - baseUrl: 'http://localhost:4200/', - framework: 'jasmine', - jasmineNodeOpts: { - showColors: true, - defaultTimeoutInterval: 30000, - print: function() {} - }, - beforeLaunch: function() { - require('ts-node').register({ - project: 'e2e/tsconfig.e2e.json' - }); - }, - onPrepare() { - jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); - } -}; diff --git a/tests/legacy-cli/e2e/assets/1.0-project/src/app/app.component.html b/tests/legacy-cli/e2e/assets/1.0-project/src/app/app.component.html deleted file mode 100644 index b6931b538a2c..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/src/app/app.component.html +++ /dev/null @@ -1,3 +0,0 @@ -

- {{title}} -

diff --git a/tests/legacy-cli/e2e/assets/1.0-project/src/app/app.component.spec.ts b/tests/legacy-cli/e2e/assets/1.0-project/src/app/app.component.spec.ts deleted file mode 100644 index c740bcd745ed..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/src/app/app.component.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { TestBed, async } from '@angular/core/testing'; - -import { AppComponent } from './app.component'; - -describe('AppComponent', () => { - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ - AppComponent - ], - }).compileComponents(); - })); - - it('should create the app', async(() => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.debugElement.componentInstance; - expect(app).toBeTruthy(); - })); - - it(`should have as title 'app works!'`, async(() => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.debugElement.componentInstance; - expect(app.title).toEqual('app works!'); - })); - - it('should render title in a h1 tag', async(() => { - const fixture = TestBed.createComponent(AppComponent); - fixture.detectChanges(); - const compiled = fixture.debugElement.nativeElement; - expect(compiled.querySelector('h1').textContent).toContain('app works!'); - })); -}); diff --git a/tests/legacy-cli/e2e/assets/1.0-project/src/app/app.component.ts b/tests/legacy-cli/e2e/assets/1.0-project/src/app/app.component.ts deleted file mode 100644 index ff63e0504887..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/src/app/app.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.css'] -}) -export class AppComponent { - title = 'app works!'; -} diff --git a/tests/legacy-cli/e2e/assets/1.0-project/src/app/app.module.ts b/tests/legacy-cli/e2e/assets/1.0-project/src/app/app.module.ts deleted file mode 100644 index 67ae49119baa..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/src/app/app.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BrowserModule } from '@angular/platform-browser'; -import { NgModule } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { HttpModule } from '@angular/http'; - -import { AppComponent } from './app.component'; - -@NgModule({ - declarations: [ - AppComponent - ], - imports: [ - BrowserModule, - FormsModule, - HttpModule - ], - providers: [], - bootstrap: [AppComponent] -}) -export class AppModule { } diff --git a/tests/legacy-cli/e2e/assets/1.0-project/src/environments/environment.prod.ts b/tests/legacy-cli/e2e/assets/1.0-project/src/environments/environment.prod.ts deleted file mode 100644 index 3612073bc31c..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/src/environments/environment.prod.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const environment = { - production: true -}; diff --git a/tests/legacy-cli/e2e/assets/1.0-project/src/environments/environment.ts b/tests/legacy-cli/e2e/assets/1.0-project/src/environments/environment.ts deleted file mode 100644 index b7f639aecac5..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/src/environments/environment.ts +++ /dev/null @@ -1,8 +0,0 @@ -// The file contents for the current environment will overwrite these during build. -// The build system defaults to the dev environment which uses `environment.ts`, but if you do -// `ng build --env=prod` then `environment.prod.ts` will be used instead. -// The list of which env maps to which file can be found in `.angular-cli.json`. - -export const environment = { - production: false -}; diff --git a/tests/legacy-cli/e2e/assets/1.0-project/src/favicon.ico b/tests/legacy-cli/e2e/assets/1.0-project/src/favicon.ico deleted file mode 100644 index 8081c7ceaf2b..000000000000 Binary files a/tests/legacy-cli/e2e/assets/1.0-project/src/favicon.ico and /dev/null differ diff --git a/tests/legacy-cli/e2e/assets/1.0-project/src/index.html b/tests/legacy-cli/e2e/assets/1.0-project/src/index.html deleted file mode 100644 index 06219472244b..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/src/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - OneOhProject - - - - - - - Loading... - - diff --git a/tests/legacy-cli/e2e/assets/1.0-project/src/main.ts b/tests/legacy-cli/e2e/assets/1.0-project/src/main.ts deleted file mode 100644 index a9ca1caf8cee..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/src/main.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { enableProdMode } from '@angular/core'; -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; - -import { AppModule } from './app/app.module'; -import { environment } from './environments/environment'; - -if (environment.production) { - enableProdMode(); -} - -platformBrowserDynamic().bootstrapModule(AppModule); diff --git a/tests/legacy-cli/e2e/assets/1.0-project/src/polyfills.ts b/tests/legacy-cli/e2e/assets/1.0-project/src/polyfills.ts deleted file mode 100644 index a8f13561140d..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/src/polyfills.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * This file includes polyfills needed by Angular and is loaded before the app. - * You can add your own extra polyfills to this file. - * - * This file is divided into 2 sections: - * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. - * 2. Application imports. Files imported after ZoneJS that should be loaded before your main - * file. - * - * The current setup is for so-called "evergreen" browsers; the last versions of browsers that - * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), - * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. - * - * Learn more in https://angular.io/guide/browser-support - */ - -/*************************************************************************************************** - * BROWSER POLYFILLS - */ - -/** IE9, IE10 and IE11 requires all of the following polyfills. **/ -// import 'core-js/es6/symbol'; -// import 'core-js/es6/object'; -// import 'core-js/es6/function'; -// import 'core-js/es6/parse-int'; -// import 'core-js/es6/parse-float'; -// import 'core-js/es6/number'; -// import 'core-js/es6/math'; -// import 'core-js/es6/string'; -// import 'core-js/es6/date'; -// import 'core-js/es6/array'; -// import 'core-js/es6/regexp'; -// import 'core-js/es6/map'; -// import 'core-js/es6/set'; - -/** IE10 and IE11 requires the following for NgClass support on SVG elements */ -// import 'classlist.js'; // Run `npm install --save classlist.js`. - -/** IE10 and IE11 requires the following to support `@angular/animation`. */ -// import 'web-animations-js'; // Run `npm install --save web-animations-js`. - - -/** Evergreen browsers require these. **/ -import 'core-js/es6/reflect'; -import 'core-js/es7/reflect'; - - -/** ALL Firefox browsers require the following to support `@angular/animation`. **/ -// import 'web-animations-js'; // Run `npm install --save web-animations-js`. - - - -/*************************************************************************************************** - * Zone JS is required by Angular itself. - */ -import 'zone.js/dist/zone'; // Included with Angular CLI. - - - -/*************************************************************************************************** - * APPLICATION IMPORTS - */ - -/** - * Date, currency, decimal and percent pipes. - * Needed for: All but Chrome, Firefox, Edge, IE11 and Safari 10 - */ -// import 'intl'; // Run `npm install --save intl`. diff --git a/tests/legacy-cli/e2e/assets/1.0-project/src/test.ts b/tests/legacy-cli/e2e/assets/1.0-project/src/test.ts deleted file mode 100644 index 9bf72267e9b1..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/src/test.ts +++ /dev/null @@ -1,32 +0,0 @@ -// This file is required by karma.conf.js and loads recursively all the .spec and framework files - -import 'zone.js/dist/long-stack-trace-zone'; -import 'zone.js/dist/proxy.js'; -import 'zone.js/dist/sync-test'; -import 'zone.js/dist/jasmine-patch'; -import 'zone.js/dist/async-test'; -import 'zone.js/dist/fake-async-test'; -import { getTestBed } from '@angular/core/testing'; -import { - BrowserDynamicTestingModule, - platformBrowserDynamicTesting -} from '@angular/platform-browser-dynamic/testing'; - -// Unfortunately there's no typing for the `__karma__` variable. Just declare it as any. -declare var __karma__: any; -declare var require: any; - -// Prevent Karma from running prematurely. -__karma__.loaded = function () {}; - -// First, initialize the Angular testing environment. -getTestBed().initTestEnvironment( - BrowserDynamicTestingModule, - platformBrowserDynamicTesting() -); -// Then we find all the tests. -const context = require.context('./', true, /\.spec\.ts$/); -// And load the modules. -context.keys().map(context); -// Finally, start Karma to run the tests. -__karma__.start(); diff --git a/tests/legacy-cli/e2e/assets/1.0-project/src/tsconfig.app.json b/tests/legacy-cli/e2e/assets/1.0-project/src/tsconfig.app.json deleted file mode 100644 index 5e2507db58c9..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/src/tsconfig.app.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../out-tsc/app", - "module": "es2015", - "baseUrl": "", - "types": [] - }, - "exclude": [ - "test.ts", - "**/*.spec.ts" - ] -} diff --git a/tests/legacy-cli/e2e/assets/1.0-project/src/tsconfig.spec.json b/tests/legacy-cli/e2e/assets/1.0-project/src/tsconfig.spec.json deleted file mode 100644 index 510e3f1fdae1..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/src/tsconfig.spec.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../out-tsc/spec", - "module": "commonjs", - "target": "es5", - "baseUrl": "", - "types": [ - "jasmine", - "node" - ] - }, - "files": [ - "test.ts" - ], - "include": [ - "**/*.spec.ts", - "**/*.d.ts" - ] -} diff --git a/tests/legacy-cli/e2e/assets/1.0-project/src/typings.d.ts b/tests/legacy-cli/e2e/assets/1.0-project/src/typings.d.ts deleted file mode 100644 index ef5c7bd62057..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/src/typings.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/* SystemJS module definition */ -declare var module: NodeModule; -interface NodeModule { - id: string; -} diff --git a/tests/legacy-cli/e2e/assets/1.0-project/tsconfig.json b/tests/legacy-cli/e2e/assets/1.0-project/tsconfig.json deleted file mode 100644 index a35a8ee3a40d..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compileOnSave": false, - "compilerOptions": { - "outDir": "./dist/out-tsc", - "baseUrl": "src", - "sourceMap": true, - "declaration": false, - "moduleResolution": "node", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "target": "es5", - "typeRoots": [ - "node_modules/@types" - ], - "lib": [ - "es2016", - "dom" - ] - } -} diff --git a/tests/legacy-cli/e2e/assets/1.0-project/tslint.json b/tests/legacy-cli/e2e/assets/1.0-project/tslint.json deleted file mode 100644 index 9113f1368b22..000000000000 --- a/tests/legacy-cli/e2e/assets/1.0-project/tslint.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "rulesDirectory": [ - "node_modules/codelyzer" - ], - "rules": { - "callable-types": true, - "class-name": true, - "comment-format": [ - true, - "check-space" - ], - "curly": true, - "eofline": true, - "forin": true, - "import-blacklist": [true, "rxjs"], - "import-spacing": true, - "indent": [ - true, - "spaces" - ], - "interface-over-type-literal": true, - "label-position": true, - "max-line-length": [ - true, - 140 - ], - "member-access": false, - "member-ordering": [ - true, - "static-before-instance", - "variables-before-functions" - ], - "no-arg": true, - "no-bitwise": true, - "no-console": [ - true, - "debug", - "info", - "time", - "timeEnd", - "trace" - ], - "no-construct": true, - "no-debugger": true, - "no-duplicate-variable": true, - "no-empty": false, - "no-empty-interface": true, - "no-eval": true, - "no-inferrable-types": [true, "ignore-params"], - "no-shadowed-variable": true, - "no-string-literal": false, - "no-string-throw": true, - "no-switch-case-fall-through": true, - "no-trailing-whitespace": true, - "no-unused-expression": true, - "no-use-before-declare": true, - "no-var-keyword": true, - "object-literal-sort-keys": false, - "one-line": [ - true, - "check-open-brace", - "check-catch", - "check-else", - "check-whitespace" - ], - "prefer-const": true, - "quotemark": [ - true, - "single" - ], - "radix": true, - "semicolon": [ - "always" - ], - "triple-equals": [ - true, - "allow-null-check" - ], - "typedef-whitespace": [ - true, - { - "call-signature": "nospace", - "index-signature": "nospace", - "parameter": "nospace", - "property-declaration": "nospace", - "variable-declaration": "nospace" - } - ], - "typeof-compare": true, - "unified-signatures": true, - "variable-name": false, - "whitespace": [ - true, - "check-branch", - "check-decl", - "check-operator", - "check-separator", - "check-type" - ], - - "directive-selector": [true, "attribute", "app", "camelCase"], - "component-selector": [true, "element", "app", "kebab-case"], - "use-input-property-decorator": true, - "use-output-property-decorator": true, - "use-host-property-decorator": true, - "no-input-rename": true, - "no-output-rename": true, - "use-life-cycle-interface": true, - "use-pipe-transform-interface": true, - "component-class-suffix": true, - "directive-class-suffix": true, - "no-access-missing-member": true, - "templates-use-public": true, - "invoke-injectable": true - } -} diff --git a/tests/legacy-cli/e2e/assets/1.7-project/.angular-cli.json b/tests/legacy-cli/e2e/assets/1.7-project/.angular-cli.json deleted file mode 100644 index 0e54ed353cee..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/.angular-cli.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "$schema": "./node_modules/@angular/cli/lib/config/schema.json", - "project": { - "name": "latest-project" - }, - "apps": [ - { - "root": "src", - "outDir": "dist", - "assets": [ - "assets", - "favicon.ico" - ], - "index": "index.html", - "main": "main.ts", - "polyfills": "polyfills.ts", - "test": "test.ts", - "tsconfig": "tsconfig.app.json", - "testTsconfig": "tsconfig.spec.json", - "prefix": "app", - "styles": [ - "styles.css" - ], - "scripts": [], - "environmentSource": "environments/environment.ts", - "environments": { - "dev": "environments/environment.ts", - "prod": "environments/environment.prod.ts" - } - } - ], - "e2e": { - "protractor": { - "config": "./protractor.conf.js" - } - }, - "lint": [ - { - "project": "src/tsconfig.app.json", - "exclude": "**/node_modules/**" - }, - { - "project": "src/tsconfig.spec.json", - "exclude": "**/node_modules/**" - }, - { - "project": "e2e/tsconfig.e2e.json", - "exclude": "**/node_modules/**" - } - ], - "test": { - "karma": { - "config": "./karma.conf.js" - } - }, - "defaults": { - "styleExt": "css", - "component": {} - } -} diff --git a/tests/legacy-cli/e2e/assets/1.7-project/.editorconfig b/tests/legacy-cli/e2e/assets/1.7-project/.editorconfig deleted file mode 100644 index 6e87a003da89..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/.editorconfig +++ /dev/null @@ -1,13 +0,0 @@ -# Editor configuration, see http://editorconfig.org -root = true - -[*] -charset = utf-8 -indent_style = space -indent_size = 2 -insert_final_newline = true -trim_trailing_whitespace = true - -[*.md] -max_line_length = off -trim_trailing_whitespace = false diff --git a/tests/legacy-cli/e2e/assets/1.7-project/.gitignore b/tests/legacy-cli/e2e/assets/1.7-project/.gitignore deleted file mode 100644 index 4ae2c37916a5..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/.gitignore +++ /dev/null @@ -1,43 +0,0 @@ -# See http://help.github.com/ignore-files/ for more about ignoring files. - -# compiled output -/dist -/dist-server -/tmp -/out-tsc - -# dependencies -/node_modules - -# IDEs and editors -/.idea -.project -.classpath -.c9/ -*.launch -.settings/ -*.sublime-workspace - -# IDE - VSCode -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json - -# misc -/.sass-cache -/connect.lock -/coverage -/libpeerconnection.log -npm-debug.log -testem.log -/typings - -# e2e -/e2e/*.js -/e2e/*.map - -# System Files -.DS_Store -Thumbs.db diff --git a/tests/legacy-cli/e2e/assets/1.7-project/README.md b/tests/legacy-cli/e2e/assets/1.7-project/README.md deleted file mode 100644 index 281d381e31db..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# LatestProject - -This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 6.0.0-beta.1. - -## Development server - -Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. - -## Code scaffolding - -Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. - -## Build - -Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `-prod` flag for a production build. - -## Running unit tests - -Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). - -## Running end-to-end tests - -Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). - -## Further help - -To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). diff --git a/tests/legacy-cli/e2e/assets/1.7-project/e2e/app.e2e-spec.ts b/tests/legacy-cli/e2e/assets/1.7-project/e2e/app.e2e-spec.ts deleted file mode 100644 index 343be839f981..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/e2e/app.e2e-spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { AppPage } from './app.po'; - -describe('latest-project App', () => { - let page: AppPage; - - beforeEach(() => { - page = new AppPage(); - }); - - it('should display welcome message', () => { - page.navigateTo(); - expect(page.getParagraphText()).toEqual('Welcome to app!'); - }); -}); diff --git a/tests/legacy-cli/e2e/assets/1.7-project/e2e/app.po.ts b/tests/legacy-cli/e2e/assets/1.7-project/e2e/app.po.ts deleted file mode 100644 index 82ea75ba504a..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/e2e/app.po.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { browser, by, element } from 'protractor'; - -export class AppPage { - navigateTo() { - return browser.get('/'); - } - - getParagraphText() { - return element(by.css('app-root h1')).getText(); - } -} diff --git a/tests/legacy-cli/e2e/assets/1.7-project/e2e/tsconfig.e2e.json b/tests/legacy-cli/e2e/assets/1.7-project/e2e/tsconfig.e2e.json deleted file mode 100644 index 1d9e5edf0965..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/e2e/tsconfig.e2e.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../out-tsc/e2e", - "baseUrl": "./", - "module": "commonjs", - "target": "es5", - "types": [ - "jasmine", - "jasminewd2", - "node" - ] - } -} diff --git a/tests/legacy-cli/e2e/assets/1.7-project/karma.conf.js b/tests/legacy-cli/e2e/assets/1.7-project/karma.conf.js deleted file mode 100644 index 7754379974fb..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/karma.conf.js +++ /dev/null @@ -1,34 +0,0 @@ -/*eslint-disable*/ -// Karma configuration file, see link for more information -// https://karma-runner.github.io/1.0/config/configuration-file.html - -module.exports = function (config) { - config.set({ - basePath: '', - frameworks: ['jasmine', '@angular/cli'], - plugins: [ - require('karma-jasmine'), - require('karma-chrome-launcher'), - require('karma-jasmine-html-reporter'), - require('karma-coverage-istanbul-reporter'), - require('@angular/cli/plugins/karma') - ], - client: { - clearContext: false // leave Jasmine Spec Runner output visible in browser - }, - coverageIstanbulReporter: { - reports: ['html', 'lcovonly'], - fixWebpackSourcePaths: true - }, - angularCli: { - environment: 'dev' - }, - reporters: ['progress', 'kjhtml'], - port: 9876, - colors: true, - logLevel: config.LOG_INFO, - autoWatch: true, - browsers: ['Chrome'], - singleRun: false - }); -}; diff --git a/tests/legacy-cli/e2e/assets/1.7-project/package.json b/tests/legacy-cli/e2e/assets/1.7-project/package.json deleted file mode 100644 index a47925f53ada..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "latest-project", - "version": "0.0.0", - "license": "MIT", - "scripts": { - "ng": "ng", - "start": "ng serve", - "build": "ng build --prod", - "test": "ng test", - "lint": "ng lint", - "e2e": "ng e2e" - }, - "private": true, - "dependencies": { - "@angular/animations": "^5.2.0", - "@angular/common": "^5.2.0", - "@angular/compiler": "^5.2.0", - "@angular/core": "^5.2.0", - "@angular/forms": "^5.2.0", - "@angular/http": "^5.2.0", - "@angular/platform-browser": "^5.2.0", - "@angular/platform-browser-dynamic": "^5.2.0", - "@angular/router": "^5.2.0", - "core-js": "^2.4.1", - "rxjs": "^5.5.6", - "zone.js": "^0.8.19" - }, - "devDependencies": { - "@angular/cli": "0.0.0", - "@angular/compiler-cli": "^5.2.0", - "@angular/language-service": "^5.2.0", - "@types/jasmine": "~2.8.3", - "@types/jasminewd2": "~2.0.2", - "@types/node": "~6.0.60", - "codelyzer": "^4.0.1", - "jasmine-core": "~2.8.0", - "jasmine-spec-reporter": "~4.2.1", - "karma": "~2.0.0", - "karma-chrome-launcher": "~2.2.0", - "karma-coverage-istanbul-reporter": "^1.2.1", - "karma-jasmine": "~1.1.0", - "karma-jasmine-html-reporter": "^0.2.2", - "protractor": "~5.1.2", - "ts-node": "~4.1.0", - "tslint": "~5.9.1", - "typescript": "~2.5.3" - } -} diff --git a/tests/legacy-cli/e2e/assets/1.7-project/protractor.conf.js b/tests/legacy-cli/e2e/assets/1.7-project/protractor.conf.js deleted file mode 100644 index 7a12a87a90a2..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/protractor.conf.js +++ /dev/null @@ -1,29 +0,0 @@ -/*eslint-disable*/ -// Protractor configuration file, see link for more information -// https://github.com/angular/protractor/blob/master/lib/config.ts - -const { SpecReporter } = require('jasmine-spec-reporter'); - -exports.config = { - allScriptsTimeout: 11000, - specs: [ - './e2e/**/*.e2e-spec.ts' - ], - capabilities: { - 'browserName': 'chrome' - }, - directConnect: true, - baseUrl: 'http://localhost:4200/', - framework: 'jasmine', - jasmineNodeOpts: { - showColors: true, - defaultTimeoutInterval: 30000, - print: function () { } - }, - onPrepare() { - require('ts-node').register({ - project: 'e2e/tsconfig.e2e.json' - }); - jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); - } -}; diff --git a/tests/legacy-cli/e2e/assets/1.7-project/src/app/app.component.css b/tests/legacy-cli/e2e/assets/1.7-project/src/app/app.component.css deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/tests/legacy-cli/e2e/assets/1.7-project/src/app/app.component.html b/tests/legacy-cli/e2e/assets/1.7-project/src/app/app.component.html deleted file mode 100644 index fa2706a406ba..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/src/app/app.component.html +++ /dev/null @@ -1,20 +0,0 @@ - -
-

- Welcome to {{ title }}! -

- Angular Logo -
-

Here are some links to help you start:

- - diff --git a/tests/legacy-cli/e2e/assets/1.7-project/src/app/app.component.spec.ts b/tests/legacy-cli/e2e/assets/1.7-project/src/app/app.component.spec.ts deleted file mode 100644 index bcbdf36b3e50..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/src/app/app.component.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { TestBed, async } from '@angular/core/testing'; -import { AppComponent } from './app.component'; -describe('AppComponent', () => { - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ - AppComponent - ], - }).compileComponents(); - })); - it('should create the app', async(() => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.debugElement.componentInstance; - expect(app).toBeTruthy(); - })); - it(`should have as title 'app'`, async(() => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.debugElement.componentInstance; - expect(app.title).toEqual('app'); - })); - it('should render title in a h1 tag', async(() => { - const fixture = TestBed.createComponent(AppComponent); - fixture.detectChanges(); - const compiled = fixture.debugElement.nativeElement; - expect(compiled.querySelector('h1').textContent).toContain('Welcome to app!'); - })); -}); diff --git a/tests/legacy-cli/e2e/assets/1.7-project/src/app/app.component.ts b/tests/legacy-cli/e2e/assets/1.7-project/src/app/app.component.ts deleted file mode 100644 index 7b0f67283151..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/src/app/app.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.css'] -}) -export class AppComponent { - title = 'app'; -} diff --git a/tests/legacy-cli/e2e/assets/1.7-project/src/app/app.module.ts b/tests/legacy-cli/e2e/assets/1.7-project/src/app/app.module.ts deleted file mode 100644 index 926975afe8af..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/src/app/app.module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { BrowserModule } from '@angular/platform-browser'; -import { NgModule } from '@angular/core'; - - -import { AppComponent } from './app.component'; - - -@NgModule({ - declarations: [ - AppComponent - ], - imports: [ - BrowserModule - ], - providers: [], - bootstrap: [AppComponent] -}) -export class AppModule { } diff --git a/tests/legacy-cli/e2e/assets/1.7-project/src/assets/.gitkeep b/tests/legacy-cli/e2e/assets/1.7-project/src/assets/.gitkeep deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/tests/legacy-cli/e2e/assets/1.7-project/src/environments/environment.prod.ts b/tests/legacy-cli/e2e/assets/1.7-project/src/environments/environment.prod.ts deleted file mode 100644 index 3612073bc31c..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/src/environments/environment.prod.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const environment = { - production: true -}; diff --git a/tests/legacy-cli/e2e/assets/1.7-project/src/environments/environment.ts b/tests/legacy-cli/e2e/assets/1.7-project/src/environments/environment.ts deleted file mode 100644 index b7f639aecac5..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/src/environments/environment.ts +++ /dev/null @@ -1,8 +0,0 @@ -// The file contents for the current environment will overwrite these during build. -// The build system defaults to the dev environment which uses `environment.ts`, but if you do -// `ng build --env=prod` then `environment.prod.ts` will be used instead. -// The list of which env maps to which file can be found in `.angular-cli.json`. - -export const environment = { - production: false -}; diff --git a/tests/legacy-cli/e2e/assets/1.7-project/src/favicon.ico b/tests/legacy-cli/e2e/assets/1.7-project/src/favicon.ico deleted file mode 100644 index 8081c7ceaf2b..000000000000 Binary files a/tests/legacy-cli/e2e/assets/1.7-project/src/favicon.ico and /dev/null differ diff --git a/tests/legacy-cli/e2e/assets/1.7-project/src/index.html b/tests/legacy-cli/e2e/assets/1.7-project/src/index.html deleted file mode 100644 index 94e4418225bf..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/src/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - LatestProject - - - - - - - - - diff --git a/tests/legacy-cli/e2e/assets/1.7-project/src/main.ts b/tests/legacy-cli/e2e/assets/1.7-project/src/main.ts deleted file mode 100644 index 91ec6da5f078..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/src/main.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { enableProdMode } from '@angular/core'; -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; - -import { AppModule } from './app/app.module'; -import { environment } from './environments/environment'; - -if (environment.production) { - enableProdMode(); -} - -platformBrowserDynamic().bootstrapModule(AppModule) - .catch(err => console.log(err)); diff --git a/tests/legacy-cli/e2e/assets/1.7-project/src/polyfills.ts b/tests/legacy-cli/e2e/assets/1.7-project/src/polyfills.ts deleted file mode 100644 index e17a0b1ea437..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/src/polyfills.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * This file includes polyfills needed by Angular and is loaded before the app. - * You can add your own extra polyfills to this file. - * - * This file is divided into 2 sections: - * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. - * 2. Application imports. Files imported after ZoneJS that should be loaded before your main - * file. - * - * The current setup is for so-called "evergreen" browsers; the last versions of browsers that - * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), - * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. - * - * Learn more in https://angular.io/guide/browser-support - */ - -/*************************************************************************************************** - * BROWSER POLYFILLS - */ - -/** IE9, IE10 and IE11 requires all of the following polyfills. **/ -// import 'core-js/es6/symbol'; -// import 'core-js/es6/object'; -// import 'core-js/es6/function'; -// import 'core-js/es6/parse-int'; -// import 'core-js/es6/parse-float'; -// import 'core-js/es6/number'; -// import 'core-js/es6/math'; -// import 'core-js/es6/string'; -// import 'core-js/es6/date'; -// import 'core-js/es6/array'; -// import 'core-js/es6/regexp'; -// import 'core-js/es6/map'; -// import 'core-js/es6/weak-map'; -// import 'core-js/es6/set'; - -/** IE10 and IE11 requires the following for NgClass support on SVG elements */ -// import 'classlist.js'; // Run `npm install --save classlist.js`. - -/** IE10 and IE11 requires the following for the Reflect API. */ -// import 'core-js/es6/reflect'; - - -/** Evergreen browsers require these. **/ -// Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove. -import 'core-js/es7/reflect'; - - -/** - * Required to support Web Animations `@angular/platform-browser/animations`. - * Needed for: All but Chrome, Firefox and Opera. http://caniuse.com/#feat=web-animation - **/ -// import 'web-animations-js'; // Run `npm install --save web-animations-js`. - -/** - * By default, zone.js will patch all possible macroTask and DomEvents - * user can disable parts of macroTask/DomEvents patch by setting following flags - */ - - // (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame - // (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick - // (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames - - /* - * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js - * with the following flag, it will bypass `zone.js` patch for IE/Edge - */ -// (window as any).__Zone_enable_cross_context_check = true; - -/*************************************************************************************************** - * Zone JS is required by default for Angular itself. - */ -import 'zone.js/dist/zone'; // Included with Angular CLI. - - - -/*************************************************************************************************** - * APPLICATION IMPORTS - */ diff --git a/tests/legacy-cli/e2e/assets/1.7-project/src/styles.css b/tests/legacy-cli/e2e/assets/1.7-project/src/styles.css deleted file mode 100644 index 90d4ee0072ce..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/src/styles.css +++ /dev/null @@ -1 +0,0 @@ -/* You can add global styles to this file, and also import other style files */ diff --git a/tests/legacy-cli/e2e/assets/1.7-project/src/test.ts b/tests/legacy-cli/e2e/assets/1.7-project/src/test.ts deleted file mode 100644 index 16317897b1c5..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/src/test.ts +++ /dev/null @@ -1,20 +0,0 @@ -// This file is required by karma.conf.js and loads recursively all the .spec and framework files - -import 'zone.js/dist/zone-testing'; -import { getTestBed } from '@angular/core/testing'; -import { - BrowserDynamicTestingModule, - platformBrowserDynamicTesting -} from '@angular/platform-browser-dynamic/testing'; - -declare const require: any; - -// First, initialize the Angular testing environment. -getTestBed().initTestEnvironment( - BrowserDynamicTestingModule, - platformBrowserDynamicTesting() -); -// Then we find all the tests. -const context = require.context('./', true, /\.spec\.ts$/); -// And load the modules. -context.keys().map(context); diff --git a/tests/legacy-cli/e2e/assets/1.7-project/src/tsconfig.app.json b/tests/legacy-cli/e2e/assets/1.7-project/src/tsconfig.app.json deleted file mode 100644 index 39ba8dbacbbe..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/src/tsconfig.app.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../out-tsc/app", - "baseUrl": "./", - "module": "es2015", - "types": [] - }, - "exclude": [ - "test.ts", - "**/*.spec.ts" - ] -} diff --git a/tests/legacy-cli/e2e/assets/1.7-project/src/tsconfig.spec.json b/tests/legacy-cli/e2e/assets/1.7-project/src/tsconfig.spec.json deleted file mode 100644 index ac22a298acaa..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/src/tsconfig.spec.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../out-tsc/spec", - "baseUrl": "./", - "module": "commonjs", - "types": [ - "jasmine", - "node" - ] - }, - "files": [ - "test.ts" - ], - "include": [ - "**/*.spec.ts", - "**/*.d.ts" - ] -} diff --git a/tests/legacy-cli/e2e/assets/1.7-project/src/typings.d.ts b/tests/legacy-cli/e2e/assets/1.7-project/src/typings.d.ts deleted file mode 100644 index ef5c7bd62057..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/src/typings.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/* SystemJS module definition */ -declare var module: NodeModule; -interface NodeModule { - id: string; -} diff --git a/tests/legacy-cli/e2e/assets/1.7-project/tsconfig.json b/tests/legacy-cli/e2e/assets/1.7-project/tsconfig.json deleted file mode 100644 index a6c016bf38ad..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "compileOnSave": false, - "compilerOptions": { - "outDir": "./dist/out-tsc", - "sourceMap": true, - "declaration": false, - "moduleResolution": "node", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "target": "es5", - "typeRoots": [ - "node_modules/@types" - ], - "lib": [ - "es2017", - "dom" - ] - } -} diff --git a/tests/legacy-cli/e2e/assets/1.7-project/tslint.json b/tests/legacy-cli/e2e/assets/1.7-project/tslint.json deleted file mode 100644 index 9963d6c3954c..000000000000 --- a/tests/legacy-cli/e2e/assets/1.7-project/tslint.json +++ /dev/null @@ -1,143 +0,0 @@ -{ - "rulesDirectory": [ - "node_modules/codelyzer" - ], - "rules": { - "arrow-return-shorthand": true, - "callable-types": true, - "class-name": true, - "comment-format": [ - true, - "check-space" - ], - "curly": true, - "deprecation": { - "severity": "warn" - }, - "eofline": true, - "forin": true, - "import-blacklist": [ - true, - "rxjs", - "rxjs/Rx" - ], - "import-spacing": true, - "indent": [ - true, - "spaces" - ], - "interface-over-type-literal": true, - "label-position": true, - "max-line-length": [ - true, - 140 - ], - "member-access": false, - "member-ordering": [ - true, - { - "order": [ - "static-field", - "instance-field", - "static-method", - "instance-method" - ] - } - ], - "no-arg": true, - "no-bitwise": true, - "no-console": [ - true, - "debug", - "info", - "time", - "timeEnd", - "trace" - ], - "no-construct": true, - "no-debugger": true, - "no-duplicate-super": true, - "no-empty": false, - "no-empty-interface": true, - "no-eval": true, - "no-inferrable-types": [ - true, - "ignore-params" - ], - "no-misused-new": true, - "no-non-null-assertion": true, - "no-shadowed-variable": true, - "no-string-literal": false, - "no-string-throw": true, - "no-switch-case-fall-through": true, - "no-trailing-whitespace": true, - "no-unnecessary-initializer": true, - "no-unused-expression": true, - "no-use-before-declare": true, - "no-var-keyword": true, - "object-literal-sort-keys": false, - "one-line": [ - true, - "check-open-brace", - "check-catch", - "check-else", - "check-whitespace" - ], - "prefer-const": true, - "quotemark": [ - true, - "single" - ], - "radix": true, - "semicolon": [ - true, - "always" - ], - "triple-equals": [ - true, - "allow-null-check" - ], - "typedef-whitespace": [ - true, - { - "call-signature": "nospace", - "index-signature": "nospace", - "parameter": "nospace", - "property-declaration": "nospace", - "variable-declaration": "nospace" - } - ], - "unified-signatures": true, - "variable-name": false, - "whitespace": [ - true, - "check-branch", - "check-decl", - "check-operator", - "check-separator", - "check-type" - ], - "directive-selector": [ - true, - "attribute", - "app", - "camelCase" - ], - "component-selector": [ - true, - "element", - "app", - "kebab-case" - ], - "no-output-on-prefix": true, - "use-input-property-decorator": true, - "use-output-property-decorator": true, - "use-host-property-decorator": true, - "no-input-rename": true, - "no-output-rename": true, - "use-life-cycle-interface": true, - "use-pipe-transform-interface": true, - "component-class-suffix": true, - "directive-class-suffix": true - } -} diff --git a/tests/legacy-cli/e2e/assets/add-collection/index.js b/tests/legacy-cli/e2e/assets/add-collection/index.js deleted file mode 100644 index cf404a768650..000000000000 --- a/tests/legacy-cli/e2e/assets/add-collection/index.js +++ /dev/null @@ -1 +0,0 @@ -exports.default = (options) => tree => tree.create(options.name || 'empty-file', ''); diff --git a/tests/legacy-cli/e2e/assets/add-collection/package.json b/tests/legacy-cli/e2e/assets/add-collection/package.json deleted file mode 100644 index 1ded34bbece6..000000000000 --- a/tests/legacy-cli/e2e/assets/add-collection/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "empty-app", - "schematics": "./collection.json" -} diff --git a/tests/legacy-cli/e2e/assets/ssl/server.crt b/tests/legacy-cli/e2e/assets/ssl/server.crt deleted file mode 100644 index 6891c4c67573..000000000000 --- a/tests/legacy-cli/e2e/assets/ssl/server.crt +++ /dev/null @@ -1,23 +0,0 @@ ------BEGIN CERTIFICATE----- -MIID5jCCAs6gAwIBAgIJAJOebwfGCm61MA0GCSqGSIb3DQEBBQUAMFUxCzAJBgNV -BAYTAlVTMRAwDgYDVQQIEwdHZW9yZ2lhMRAwDgYDVQQHEwdBdGxhbnRhMRAwDgYD -VQQKEwdBbmd1bGFyMRAwDgYDVQQLEwdBbmd1bGFyMB4XDTE2MTAwNDAxMDAyMVoX -DTI2MTAwMjAxMDAyMVowVTELMAkGA1UEBhMCVVMxEDAOBgNVBAgTB0dlb3JnaWEx -EDAOBgNVBAcTB0F0bGFudGExEDAOBgNVBAoTB0FuZ3VsYXIxEDAOBgNVBAsTB0Fu -Z3VsYXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDT6Q4d1+mw81SC -4K1qLbsMn4O459XDiDDU/cGBiE0byqi6RpaB0MujCPn35xdeCf1mdDw929leEIRB -w/fCN3VwE+4ZDM7sF6SgoSDN8YT/OOush4tDu0djH110I+i1Bfg4m7gVkUnJLUCv -vMMOlD19LDqqaxdY3ojXx8gZJW9sNtUH2vCICwsZ7aNZp2NcCNKpU7LppP4IomCd -GfG501kY/UtELVgNGX+zuJwIiH/2AQZ+fsaDBBD0Azanck2M/aq5yVKMG8y/S5WP -7LMvZs8ZHPSG73QINogRTYW0EKx7nT87vmrHRtCc9u4coPdqOzQN9BigCYVkYrTv -xkOX9VDHAgMBAAGjgbgwgbUwHQYDVR0OBBYEFG4VV6/aNLx/qFIS9MhAWuyeV5OX -MIGFBgNVHSMEfjB8gBRuFVev2jS8f6hSEvTIQFrsnleTl6FZpFcwVTELMAkGA1UE -BhMCVVMxEDAOBgNVBAgTB0dlb3JnaWExEDAOBgNVBAcTB0F0bGFudGExEDAOBgNV -BAoTB0FuZ3VsYXIxEDAOBgNVBAsTB0FuZ3VsYXKCCQCTnm8HxgputTAMBgNVHRME -BTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQDO4jZT/oKVxaiWr+jV5TD+qwThl9zT -Uw/ZpFDkdbZdY/baCFaLCiJwkK9+puMOabLvm1VzcnHHWCoiUNbWpw8AOumLEnTv -ze/5OZXJ6XlA9kd9f3hDlN5zNB3S+Z2nKIrkPGfxQZ603QCbWaptip5dxgek6oDZ -YXVtnbOnPznRsG5jh07U49RO8CNebqZLzdRToLgObbqYlfRMcbUxCOHXjnB5wUlp -377Iivm4ldnCTvFOjEiDh+FByWL5xic7PjyJPZFMidiYTmsGilP9XTFC83CRZwz7 -vW+RCSlU6x8Uejz98BPmASoqCuCTUeOo+2pFelFhX9NwR/Sb6b7ybdPv ------END CERTIFICATE----- diff --git a/tests/legacy-cli/e2e/assets/ssl/server.key b/tests/legacy-cli/e2e/assets/ssl/server.key deleted file mode 100644 index e0e0af0f8da8..000000000000 --- a/tests/legacy-cli/e2e/assets/ssl/server.key +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEA0+kOHdfpsPNUguCtai27DJ+DuOfVw4gw1P3BgYhNG8qoukaW -gdDLowj59+cXXgn9ZnQ8PdvZXhCEQcP3wjd1cBPuGQzO7BekoKEgzfGE/zjrrIeL -Q7tHYx9ddCPotQX4OJu4FZFJyS1Ar7zDDpQ9fSw6qmsXWN6I18fIGSVvbDbVB9rw -iAsLGe2jWadjXAjSqVOy6aT+CKJgnRnxudNZGP1LRC1YDRl/s7icCIh/9gEGfn7G -gwQQ9AM2p3JNjP2quclSjBvMv0uVj+yzL2bPGRz0hu90CDaIEU2FtBCse50/O75q -x0bQnPbuHKD3ajs0DfQYoAmFZGK078ZDl/VQxwIDAQABAoIBAEl17kXcNo/4GqDw -QE2hoslCdwhfnhQVn1AG09ESriBnRcylccF4308aaoVM4CXicqzUuJl9IEJimWav -B7GVRinfTtfyP71KiPCCSvv5sPBFDDYYGugVAS9UjTIYzLAMbLs7CDq5zglmnZkO -Z9QjAZnl/kRbsZFGO8wJ3s0Q1Cp/ygZcvFU331K2jHXW7B4YXiFOH/lBQrjdz0Gy -WBjX4zIdNWnwarvxu46IS/0z1P1YOHM8+B1Uv54MG94A6szBdd/Vp0cQRs78t/Cu -BQ1Rnuk16Pi+ieC5K04yUgeuNusYW0PWLtPX1nKNp9z46bmD1NHKAxaoDFXr7qP3 -pZCaDMkCgYEA8mmTYrhXJTRIrOxoUwM1e3OZ0uOxVXJJ8HF6X8t+UO6dFxXB/JC9 -ZBc+94cZQapaKFOeMmd/j3L2CQIjChk5yKV/G3Io+raxIoAAKPCkMF4NQQVvvNkS -CAGl61Qa78DoF5Habumz0AC1R9P877kNTC0aPSt4lhPWgfotbZNNMlMCgYEA38nM -s4a0pZseXPkuOtPYX/3Ms3E+d70XKSFuIMCHCg79YGsQ8h/9apYcPyeYkpQ0a4gs -I3IUqMaXC2OyqWA5LU1BZv51mXb6zcb2pokZfpiSWk+7sy5XjkE9EmQxp3xHfV3c -EO/DxHfWNvtMjESMbhu0yVzM2O/Aa53Tl9lqAT0CgYEA1dXBuHyqCtyTG08zO78B -55Ny5rAJ1zkI9jvz2hr0o0nJcvqzcyruliNXXRxkcCNoglg4nXfk81JSrGGhLSBR -c6hhdoF+mqKboLZO7c5Q14WvpWK5TVoiaMOja/J2DHYbhecYS2yGPH7TargaUBDq -JP9IPRtitOhs+Z0Jg7ZDi5cCgYAMb7B6gY/kbBxh2k8hYchyfS41AqQQD2gMFxmB -pHFcs7yM8SY97l0s4S6sq8ykyKupFiYtyhcv0elu7pltJDXJOLPbv2RVpPEHInlu -g8vw5xWrAydRK9Adza5RKVRBFHz8kIy8PDbK4kX7RDfay6xqKgv/7LJNk/VDhb/O -fnyPmQKBgQDg/o8Ubf/gxA9Husnuld4DBu3wwFhkMlWqyO9QH3cKgojQ2JGSrfDz -xHhetmhionEyzg0JCaMSpzgIHY+8o/NAwc++OjNHEoYp3XWM9GTp81ROMz6b83jV -biVR9N0MhONdwF6vtzDCcJxNIUe2p4lTvLf/Xd9jaQDNXe35Gxsdyg== ------END RSA PRIVATE KEY----- diff --git a/tests/legacy-cli/e2e/assets/webpack/test-app-ng5/app/app.component.html b/tests/legacy-cli/e2e/assets/webpack/test-app-ng5/app/app.component.html deleted file mode 100644 index 5a532db9308f..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-app-ng5/app/app.component.html +++ /dev/null @@ -1,5 +0,0 @@ -
-

hello world

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

hello world

- lazy - -
- - -

DEBUG_ONLY

- - - -

PRODUCTION_ONLY

- diff --git a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/app.component.scss b/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/app.component.scss deleted file mode 100644 index ba143f5d5da1..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/app.component.scss +++ /dev/null @@ -1,15 +0,0 @@ -:host { - background-color: blue; -} - -// @ifdef DEBUG -:host::before { - content: 'DEBUG_ONLY'; -} -// @endif - -// @ifndef DEBUG -:host::before { - content: 'PRODUCTION_ONLY'; -} -// @endif diff --git a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/app.component.ts b/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/app.component.ts deleted file mode 100644 index 09a19ad8f1ac..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/app.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {Component, ViewEncapsulation} from '@angular/core'; - - -@Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.scss'], - encapsulation: ViewEncapsulation.None -}) -export class AppComponent { } diff --git a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/app.module.ts b/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/app.module.ts deleted file mode 100644 index 2bcd6c4883bd..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/app.module.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NgModule, Component } from '@angular/core'; -import { BrowserModule } from '@angular/platform-browser'; -import { RouterModule } from '@angular/router'; -import { AppComponent } from './app.component'; - -@Component({ - selector: 'home-view', - template: 'home!' -}) -export class HomeView {} - - -// @ifdef DEBUG -console.log("DEBUG_ONLY"); -// @endif - -// @ifndef DEBUG -console.log("PRODUCTION_ONLY"); -// @endif - - -@NgModule({ - declarations: [ - AppComponent, - HomeView - ], - imports: [ - BrowserModule, - RouterModule.forRoot([ - {path: 'lazy', loadChildren: './lazy.module#LazyModule'}, - {path: '', component: HomeView} - ]) - ], - bootstrap: [AppComponent] -}) -export class AppModule { } diff --git a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/feature/feature.module.ts b/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/feature/feature.module.ts deleted file mode 100644 index f464ca028b05..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/feature/feature.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {NgModule, Component} from '@angular/core'; -import {RouterModule} from '@angular/router'; - -@Component({ - selector: 'feature-component', - template: 'foo.html' -}) -export class FeatureComponent {} - -@NgModule({ - declarations: [ - FeatureComponent - ], - imports: [ - RouterModule.forChild([ - { path: '', component: FeatureComponent} - ]) - ] -}) -export class FeatureModule {} diff --git a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/feature/lazy-feature.module.ts b/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/feature/lazy-feature.module.ts deleted file mode 100644 index 8fafca158b24..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/feature/lazy-feature.module.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {NgModule, Component} from '@angular/core'; -import {RouterModule} from '@angular/router'; -import {HttpModule, Http} from '@angular/http'; - -@Component({ - selector: 'lazy-feature-comp', - template: 'lazy feature!' -}) -export class LazyFeatureComponent {} - -@NgModule({ - imports: [ - RouterModule.forChild([ - {path: '', component: LazyFeatureComponent, pathMatch: 'full'}, - {path: 'feature', loadChildren: './feature.module#FeatureModule'} - ]), - HttpModule - ], - declarations: [LazyFeatureComponent] -}) -export class LazyFeatureModule { - constructor(http: Http) {} -} diff --git a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/lazy.module.ts b/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/lazy.module.ts deleted file mode 100644 index 96da4de7515b..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/lazy.module.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {NgModule, Component} from '@angular/core'; -import {RouterModule} from '@angular/router'; -import {HttpModule, Http} from '@angular/http'; - -@Component({ - selector: 'lazy-comp', - template: 'lazy!' -}) -export class LazyComponent {} - -@NgModule({ - imports: [ - RouterModule.forChild([ - {path: '', component: LazyComponent, pathMatch: 'full'}, - {path: 'feature', loadChildren: './feature/feature.module#FeatureModule'}, - {path: 'lazy-feature', loadChildren: './feature/lazy-feature.module#LazyFeatureModule'} - ]), - HttpModule - ], - declarations: [LazyComponent] -}) -export class LazyModule { - constructor(http: Http) {} -} - -export class SecondModule {} diff --git a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/main.jit.ts b/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/main.jit.ts deleted file mode 100644 index 0a705a5daebb..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/app/main.jit.ts +++ /dev/null @@ -1,5 +0,0 @@ -import 'core-js/es7/reflect'; -import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; -import {AppModule} from './app.module'; - -platformBrowserDynamic().bootstrapModule(AppModule); diff --git a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/index.html b/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/index.html deleted file mode 100644 index 89fb0893c35d..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - Document - - - - - - - - diff --git a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/tsconfig.json b/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/tsconfig.json deleted file mode 100644 index df35f5efe991..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/not/so/source/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compilerOptions": { - "module": "es2015", - "moduleResolution": "node", - "target": "es5", - "noImplicitAny": false, - "sourceMap": true, - "mapRoot": "", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "lib": [ - "es2017", - "dom" - ], - "outDir": "lib", - "skipLibCheck": true, - "rootDir": "." - }, - "angularCompilerOptions": { - "genDir": "app/generated/", - "entryModule": "app/app.module#AppModule" - } -} diff --git a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/package.json b/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/package.json deleted file mode 100644 index 4a2637e90a24..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "test", - "license": "MIT", - "dependencies": { - "@angular/common": "^5.0.0-rc.8", - "@angular/compiler": "^5.0.0-rc.8", - "@angular/compiler-cli": "^5.0.0-rc.8", - "@angular/core": "^5.0.0-rc.8", - "@angular/http": "^5.0.0-rc.8", - "@angular/platform-browser": "^5.0.0-rc.8", - "@angular/platform-browser-dynamic": "^5.0.0-rc.8", - "@angular/platform-server": "^5.0.0-rc.8", - "@angular/router": "^5.0.0-rc.8", - "@ngtools/webpack": "0.0.0", - "core-js": "^2.4.1", - "rxjs": "^5.5.0", - "zone.js": "^0.8.14" - }, - "devDependencies": { - "node-sass": "^4.7.0", - "performance-now": "^0.2.0", - "preprocess-loader": "^0.2.2", - "raw-loader": "^0.5.1", - "sass-loader": "^6.0.0", - "typescript": "~2.4.2", - "webpack": "~4.0.1", - "webpack-cli": "~2.0.9" - } -} diff --git a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/webpack.config.js b/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/webpack.config.js deleted file mode 100644 index cf5f4020dde2..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/webpack.config.js +++ /dev/null @@ -1,38 +0,0 @@ -const ngToolsWebpack = require('@ngtools/webpack'); -const path = require('path'); - -const flags = require('./webpack.flags.json'); - -const preprocessLoader = 'preprocess-loader' + (flags.DEBUG ? '?+DEBUG' : ''); - - -module.exports = { - resolve: { - extensions: ['.ts', '.js'] - }, - entry: './not/so/source/app/main.jit.ts', - output: { - path: path.resolve('./dist'), - publicPath: 'dist/', - filename: 'app.main.js' - }, - plugins: [ - new ngToolsWebpack.AngularCompilerPlugin(require('./aotplugin.config.json')) - ], - module: { - rules: [ - { test: /\.scss$/, loaders: ['raw-loader', 'sass-loader', preprocessLoader] }, - { test: /\.css$/, loader: 'raw-loader' }, - { test: /\.html$/, loaders: ['raw-loader', preprocessLoader] }, - // Use preprocess to remove DEBUG only code. - // @ngtools/webpack must be the first (right most) loader. - { test: /\.ts$/, use: [ - { loader: preprocessLoader }, - { loader: '@ngtools/webpack' } - ] } - ] - }, - devServer: { - historyApiFallback: true - } -}; diff --git a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/webpack.flags.json b/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/webpack.flags.json deleted file mode 100644 index aac05f738dc8..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-app-weird-ng5/webpack.flags.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "DEBUG": false -} diff --git a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/app.component.html b/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/app.component.html deleted file mode 100644 index 5a532db9308f..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/app.component.html +++ /dev/null @@ -1,5 +0,0 @@ -
-

hello world

- lazy - -
diff --git a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/app.component.scss b/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/app.component.scss deleted file mode 100644 index 5cde7b922336..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/app.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -:host { - background-color: blue; -} diff --git a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/app.component.ts b/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/app.component.ts deleted file mode 100644 index 82a4059565d3..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/app.component.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {Component, ViewEncapsulation} from '@angular/core'; -import {MyInjectable} from './injectable'; - - -@Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.scss'], - encapsulation: ViewEncapsulation.None -}) -export class AppComponent { - constructor(public inj: MyInjectable) { - console.log(inj); - } -} diff --git a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/app.module.ts b/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/app.module.ts deleted file mode 100644 index 7c8a0c296448..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/app.module.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NgModule, Component } from '@angular/core'; -import { ServerModule } from '@angular/platform-server'; -import { BrowserModule } from '@angular/platform-browser'; -import { RouterModule } from '@angular/router'; - -import { AppComponent } from './app.component'; -import { MyInjectable } from './injectable'; - -@Component({ - selector: 'home-view', - template: 'home!' -}) -export class HomeView {} - - -@NgModule({ - declarations: [ - AppComponent, - HomeView - ], - imports: [ - BrowserModule.withServerTransition({ - appId: 'app' - }), - ServerModule, - RouterModule.forRoot([ - {path: 'lazy', loadChildren: './lazy.module#LazyModule'}, - {path: '', component: HomeView} - ]) - ], - providers: [MyInjectable], - bootstrap: [AppComponent] -}) -export class AppModule { - static testProp: string; -} diff --git a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/feature/feature.module.ts b/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/feature/feature.module.ts deleted file mode 100644 index f464ca028b05..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/feature/feature.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {NgModule, Component} from '@angular/core'; -import {RouterModule} from '@angular/router'; - -@Component({ - selector: 'feature-component', - template: 'foo.html' -}) -export class FeatureComponent {} - -@NgModule({ - declarations: [ - FeatureComponent - ], - imports: [ - RouterModule.forChild([ - { path: '', component: FeatureComponent} - ]) - ] -}) -export class FeatureModule {} diff --git a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/feature/lazy-feature.module.ts b/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/feature/lazy-feature.module.ts deleted file mode 100644 index 8fafca158b24..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/feature/lazy-feature.module.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {NgModule, Component} from '@angular/core'; -import {RouterModule} from '@angular/router'; -import {HttpModule, Http} from '@angular/http'; - -@Component({ - selector: 'lazy-feature-comp', - template: 'lazy feature!' -}) -export class LazyFeatureComponent {} - -@NgModule({ - imports: [ - RouterModule.forChild([ - {path: '', component: LazyFeatureComponent, pathMatch: 'full'}, - {path: 'feature', loadChildren: './feature.module#FeatureModule'} - ]), - HttpModule - ], - declarations: [LazyFeatureComponent] -}) -export class LazyFeatureModule { - constructor(http: Http) {} -} diff --git a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/injectable.ts b/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/injectable.ts deleted file mode 100644 index b357678ae77a..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/injectable.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {Injectable, Inject, ViewContainerRef} from '@angular/core'; -import {DOCUMENT} from '@angular/platform-browser'; - - -@Injectable() -export class MyInjectable { - constructor(@Inject(DOCUMENT) public doc) {} -} diff --git a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/lazy.module.ts b/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/lazy.module.ts deleted file mode 100644 index 96da4de7515b..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/lazy.module.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {NgModule, Component} from '@angular/core'; -import {RouterModule} from '@angular/router'; -import {HttpModule, Http} from '@angular/http'; - -@Component({ - selector: 'lazy-comp', - template: 'lazy!' -}) -export class LazyComponent {} - -@NgModule({ - imports: [ - RouterModule.forChild([ - {path: '', component: LazyComponent, pathMatch: 'full'}, - {path: 'feature', loadChildren: './feature/feature.module#FeatureModule'}, - {path: 'lazy-feature', loadChildren: './feature/lazy-feature.module#LazyFeatureModule'} - ]), - HttpModule - ], - declarations: [LazyComponent] -}) -export class LazyModule { - constructor(http: Http) {} -} - -export class SecondModule {} diff --git a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/main.commonjs.ts b/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/main.commonjs.ts deleted file mode 100644 index ce26d93a11de..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/main.commonjs.ts +++ /dev/null @@ -1 +0,0 @@ -export { AppModule } from './app.module'; diff --git a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/main.ts b/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/main.ts deleted file mode 100644 index 5d57aafca8ae..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/app/main.ts +++ /dev/null @@ -1,12 +0,0 @@ -import 'core-js/es7/reflect'; -import {platformDynamicServer, renderModule} from '@angular/platform-server'; -import {AppModule} from './app.module'; - -AppModule.testProp = 'testing'; - -platformDynamicServer().bootstrapModule(AppModule); - -renderModule(AppModule, { - document: '', - url: '/' -}); diff --git a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/index.html b/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/index.html deleted file mode 100644 index 89fb0893c35d..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - Document - - - - - - - - diff --git a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/index.js b/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/index.js deleted file mode 100644 index bdfb2e792acd..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/index.js +++ /dev/null @@ -1,12 +0,0 @@ -const fs = require('fs'); -const { AppModuleNgFactory } = require('./dist/app.main'); -const { renderModuleFactory } = require('@angular/platform-server'); - -require('zone.js/dist/zone-node'); - -renderModuleFactory(AppModuleNgFactory, { - url: '/', - document: '' -}).then(html => { - fs.writeFileSync('dist/index.html', html); -}) diff --git a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/package.json b/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/package.json deleted file mode 100644 index 96368897352a..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "test", - "license": "MIT", - "dependencies": { - "@angular/animations": "^5.0.0", - "@angular/common": "^5.0.0", - "@angular/compiler": "^5.0.0", - "@angular/compiler-cli": "^5.0.0", - "@angular/core": "^5.0.0", - "@angular/http": "^5.0.0", - "@angular/platform-browser": "^5.0.0", - "@angular/platform-browser-dynamic": "^5.0.0", - "@angular/platform-server": "^5.0.0", - "@angular/router": "^5.0.0", - "@ngtools/webpack": "0.0.0", - "core-js": "^2.4.1", - "rxjs": "^5.4.2", - "zone.js": "^0.8.14" - }, - "devDependencies": { - "node-sass": "^4.5.0", - "performance-now": "^0.2.0", - "raw-loader": "^0.5.1", - "sass-loader": "^6.0.3", - "typescript": "~2.5.0", - "webpack": "~4.0.1", - "webpack-cli": "~2.0.9" - } -} diff --git a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/tsconfig.json b/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/tsconfig.json deleted file mode 100644 index 5822e780bfc7..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": "", - "module": "es2015", - "moduleResolution": "node", - "target": "es5", - "noImplicitAny": false, - "sourceMap": true, - "mapRoot": "", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "lib": [ - "es2017", - "dom" - ], - "outDir": "lib", - "skipLibCheck": true, - "rootDir": "." - }, - "angularCompilerOptions": { - "genDir": "./app/ngfactory", - "entryModule": "app/app.module#AppModule" - } -} diff --git a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/webpack.config.js b/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/webpack.config.js deleted file mode 100644 index 2905841b3e54..000000000000 --- a/tests/legacy-cli/e2e/assets/webpack/test-server-app-ng5/webpack.config.js +++ /dev/null @@ -1,33 +0,0 @@ -const { AngularCompilerPlugin, PLATFORM } = require('@ngtools/webpack'); -const path = require('path'); - -module.exports = { - resolve: { - extensions: ['.ts', '.js'] - }, - target: 'web', - entry: './app/main.ts', - output: { - path: path.resolve('./dist'), - publicPath: 'dist/', - filename: 'app.main.js' - }, - plugins: [ - new AngularCompilerPlugin({ - tsConfigPath: './tsconfig.json', - mainPath: './app/main.ts', - platform: PLATFORM.Server - }) - ], - module: { - rules: [ - { test: /\.scss$/, loaders: ['raw-loader', 'sass-loader'] }, - { test: /\.css$/, loader: 'raw-loader' }, - { test: /\.html$/, loader: 'raw-loader' }, - { test: /\.ts$/, loader: '@ngtools/webpack' } - ] - }, - devServer: { - historyApiFallback: true - } -}; diff --git a/tests/legacy-cli/e2e/setup/015-build.ts b/tests/legacy-cli/e2e/setup/015-build.ts deleted file mode 100644 index faf9b3f5b9c8..000000000000 --- a/tests/legacy-cli/e2e/setup/015-build.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {join} from 'path'; -import {getGlobalVariable} from '../utils/env'; -import {npm} from '../utils/process'; -import {updateJsonFile} from '../utils/project'; - -const packages = require('../../../../lib/packages').packages; - -export default function() { - const argv = getGlobalVariable('argv'); - - if (argv.nobuild) { - return; - } - - return npm('run', 'build', '--', '--local'); -} diff --git a/tests/legacy-cli/e2e/setup/100-global-cli.ts b/tests/legacy-cli/e2e/setup/100-global-cli.ts deleted file mode 100644 index 31a862ff5b80..000000000000 --- a/tests/legacy-cli/e2e/setup/100-global-cli.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {silentNpm, exec} from '../utils/process'; -import {getGlobalVariable} from '../utils/env'; - -const packages = require('../../../../lib/packages').packages; - - -export default function () { - return Promise.resolve() - .then(() => { - const argv = getGlobalVariable('argv'); - if (argv.noglobal) { - return; - } - - // Install global Angular CLI. - // --unsafe-perm is needed for circleci - // because of https://github.com/sass/node-sass/issues/2006 - return silentNpm('install', '-g', packages['@angular/cli'].tar, '--unsafe-perm'); - }) - .then(() => { - return exec(process.platform.startsWith('win') ? 'where' : 'which', 'ng') - // Ignore errors on `which`. - .catch(() => {}); - }); -} diff --git a/tests/legacy-cli/e2e/setup/200-create-tmp-dir.ts b/tests/legacy-cli/e2e/setup/200-create-tmp-dir.ts deleted file mode 100644 index ec8cdd032913..000000000000 --- a/tests/legacy-cli/e2e/setup/200-create-tmp-dir.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {dirname} from 'path'; -import {setGlobalVariable, getGlobalVariable} from '../utils/env'; - - -const temp = require('temp'); - -export default function() { - const argv = getGlobalVariable('argv'); - - // Get to a temporary directory. - let tempRoot = argv.tmpdir || temp.mkdirSync('angular-cli-e2e-'); - if (argv.reuse) { - tempRoot = dirname(argv.reuse); - } - console.log(` Using "${tempRoot}" as temporary directory for a new project.`); - setGlobalVariable('tmp-root', tempRoot); - process.chdir(tempRoot); -} diff --git a/tests/legacy-cli/e2e/setup/300-log-environment.ts b/tests/legacy-cli/e2e/setup/300-log-environment.ts deleted file mode 100644 index c6c59b01b4be..000000000000 --- a/tests/legacy-cli/e2e/setup/300-log-environment.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {node, ng, npm} from '../utils/process'; - -const packages = require('../../../../lib/packages').packages; - - -export default function() { - return Promise.resolve() - .then(() => console.log('Environment:')) - .then(() => { - Object.keys(process.env).forEach(envName => { - console.log(` ${envName}: ${process.env[envName].replace(/[\n\r]+/g, '\n ')}`); - }); - }) - // TODO: Investigate why this fails - // .then(() => { - // console.log('Packages:'); - // console.log(JSON.stringify(packages, null, 2)); - // }) - .then(() => node('--version')) - .then(() => npm('--version')) - .then(() => ng('version')); -} diff --git a/tests/legacy-cli/e2e/setup/500-create-project.ts b/tests/legacy-cli/e2e/setup/500-create-project.ts deleted file mode 100644 index a0a30032dba3..000000000000 --- a/tests/legacy-cli/e2e/setup/500-create-project.ts +++ /dev/null @@ -1,53 +0,0 @@ -import {join} from 'path'; -import {git, ng, silentNpm} from '../utils/process'; -import { expectFileToExist, writeFile } from '../utils/fs'; -import { - useSha, - useNgVersion, - useCIChrome, - useCIDefaults, - useBuiltPackages, - useDevKit, - useDevKitSnapshots, - updateJsonFile, -} from '../utils/project'; -import {gitClean, gitCommit} from '../utils/git'; -import {getGlobalVariable} from '../utils/env'; - - -export default async function() { - const argv = getGlobalVariable('argv'); - - if (argv.noproject) { - return; - } - - if (argv.reuse) { - process.chdir(argv.reuse); - await gitClean(); - } else { - await ng('new', 'test-project', '--skip-install'); - await expectFileToExist(join(process.cwd(), 'test-project')); - process.chdir('./test-project'); - } - - return Promise.resolve() - .then(() => useBuiltPackages()) - .then(() => argv.devkit ? useDevKit(argv.devkit) : useDevKitSnapshots()) - .then(() => useCIChrome('e2e')) - .then(() => useCIChrome('src')) - .then(() => argv['ng-version'] ? useNgVersion(argv['ng-version']) : Promise.resolve()) - .then(() => argv['ng-snapshots'] || argv['ng-tag'] ? useSha() : Promise.resolve()) - // npm link on Circle CI is very noisy. - .then(() => silentNpm('install')) - .then(() => ng('version')) - // Force sourcemaps to be from the root of the filesystem. - .then(() => updateJsonFile('tsconfig.json', json => { - json['compilerOptions']['sourceRoot'] = '/'; - })) - .then(() => git('config', 'user.email', 'angular-core+e2e@google.com')) - .then(() => git('config', 'user.name', 'Angular CLI E2e')) - .then(() => git('config', 'commit.gpgSign', 'false')) - .then(() => useCIDefaults()) - .then(() => gitCommit('tsconfig-e2e-update')); -} diff --git a/tests/legacy-cli/e2e/tests/basic/aot.ts b/tests/legacy-cli/e2e/tests/basic/aot.ts deleted file mode 100644 index 95fb6442d6ad..000000000000 --- a/tests/legacy-cli/e2e/tests/basic/aot.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ng } from '../../utils/process'; -import {expectFileToMatch} from '../../utils/fs'; - -export default function() { - return ng('build', '--aot=true') - .then(() => expectFileToMatch('dist/test-project/main.js', - /platformBrowser.*bootstrapModuleFactory.*AppModuleNgFactory/)); -} diff --git a/tests/legacy-cli/e2e/tests/basic/assets.ts b/tests/legacy-cli/e2e/tests/basic/assets.ts deleted file mode 100644 index f35ebc69792a..000000000000 --- a/tests/legacy-cli/e2e/tests/basic/assets.ts +++ /dev/null @@ -1,196 +0,0 @@ -// import * as path from 'path'; -import { - writeMultipleFiles, - createDir, - expectFileToMatch, - expectFileToExist -} from '../../utils/fs'; -import { ng } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; -import { expectToFail } from '../../utils/utils'; -import {getGlobalVariable} from '../../utils/env'; - - -// const temp = require('temp'); -// const tempDir = path.join(temp.mkdirSync('angular-cli-e2e-assets-'), 'out'); - -// tslint:disable:max-line-length -export default function () { - // Disable parts of it in webpack tests. - const ejected = getGlobalVariable('argv').eject; - - // TODO: update test - return; - - return Promise.resolve() - .then(_ => createDir('./src/folder')) - .then(_ => createDir('./node_modules/some-package/')) - // Write assets. - .then(_ => writeMultipleFiles({ - './src/folder/.gitkeep': '', - './src/folder/folder-asset.txt': 'folder-asset.txt', - './src/glob-asset.txt': 'glob-asset.txt', - './src/output-asset.txt': 'output-asset.txt', - './node_modules/some-package/node_modules-asset.txt': 'node_modules-asset.txt', - })) - // TODO(architect): Review allowOutsideOutDir logic inside build-angular. - // // Add invalid asset config in angular.json. - // .then(() => updateJsonFile('angular.json', configJson => { - // const app = configJson['apps'][0]; - // app['assets'] = [ - // { 'glob': '**/*', 'input': '../node_modules/some-package/', 'output': '../temp' } - // ]; - // })) - // .then(() => expectToFail(() => ng('build'))) - - // // Set an exception for the invalid asset config in angular.json. - // .then(() => updateJsonFile('angular.json', configJson => { - // const app = configJson['apps'][0]; - // app['assets'] = [ - // { 'glob': '**/*', 'input': '../node_modules/some-package/', 'output': '../temp', - // 'allowOutsideOutDir': true } - // ]; - // })) - // .then(() => ng('build')) - - // // This asset should fail even with the exception above. - // .then(() => updateJsonFile('angular.json', configJson => { - // const app = configJson['apps'][0]; - // app['assets'] = [ - // { 'glob': '**/*', 'input': '../node_modules/some-package/', 'output': '../../temp', - // 'allowOutsideOutDir': true } - // ]; - // })) - // .then(() => expectToFail(() => ng('build'))) - - // // This asset will not fail with the exception above. - // .then(() => updateJsonFile('angular.json', configJson => { - // const app = configJson['apps'][0]; - // app['outDir'] = tempDir; - // app['assets'] = [ - // { 'glob': '**/*', 'input': '../node_modules/some-package/', 'output': tempDir, - // 'allowOutsideOutDir': true } - // ]; - // })) - // .then(() => ng('build')) - // .then(() => updateJsonFile('angular.json', configJson => { - // const app = configJson['apps'][0]; - // app['outDir'] = 'dist'; - // }) - - // // This asset should also fail from reading from outside the project. - // .then(() => updateJsonFile('angular.json', configJson => { - // const app = configJson['apps'][0]; - // app['assets'] = [ - // { 'glob': '**/*', 'input': '/temp-folder/outside/of/project', 'output': 'temp' } - // ]; - // })) - // .then(() => expectToFail(() => ng('build'))) - - // Add asset config in angular.json. - .then(() => updateJsonFile('angular.json', workspaceJson => { - const assets = [ - { 'glob': '**/*', 'input': 'src/folder', 'output': 'folder' }, - { 'glob': 'glob-asset.txt' }, - { 'glob': 'output-asset.txt', 'output': 'output-folder' }, - { 'glob': '**/*', 'input': 'node_modules/some-package/', 'output': 'package-folder' }, - ]; - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.assets = assets; - appArchitect.test.options.assets = assets; - })) - // Test files are present on build output. - .then(() => ng('build')) - .then(() => expectFileToMatch('./dist/test-project/folder/folder-asset.txt', 'folder-asset.txt')) - - .then(() => expectFileToMatch('./dist/test-project/glob-asset.txt', 'glob-asset.txt')) - .then(() => expectFileToMatch('./dist/test-project/output-folder/output-asset.txt', 'output-asset.txt')) - .then(() => expectFileToMatch('./dist/test-project/package-folder/node_modules-asset.txt', - 'node_modules-asset.txt')) - // .gitkeep shouldn't be copied. - .then(() => expectToFail(() => expectFileToExist('dist/assets/.gitkeep'))) - // Update app to test assets are present. - .then(_ => !ejected && writeMultipleFiles({ - 'src/app/app.module.ts': ` - import { BrowserModule } from '@angular/platform-browser'; - import { NgModule } from '@angular/core'; - import { FormsModule } from '@angular/forms'; - import { HttpModule } from '@angular/http'; - import { AppComponent } from './app.component'; - - @NgModule({ - declarations: [ - AppComponent - ], - imports: [ - BrowserModule, - FormsModule, - HttpModule - ], - providers: [], - bootstrap: [AppComponent] - }) - export class AppModule { } - `, - 'src/app/app.component.ts': ` - import { Component } from '@angular/core'; - import { Http, Response } from '@angular/http'; - - @Component({ - selector: 'app-root', - template: '

{{asset.content }}

' - }) - export class AppComponent { - public assets = [ - { path: './folder/folder-asset.txt', content: '' }, - { path: './glob-asset.txt', content: '' }, - { path: './output-folder/output-asset.txt', content: '' }, - { path: './package-folder/node_modules-asset.txt', content: '' }, - ]; - constructor(private http: Http) { - this.assets.forEach(asset => http.get(asset.path) - .subscribe(res => asset.content = res['_body'])); - } - }`, - 'src/app/app.component.spec.ts': ` - import { TestBed, async } from '@angular/core/testing'; - import { HttpModule } from '@angular/http'; - import { AppComponent } from './app.component'; - - describe('AppComponent', () => { - beforeEach(async(() => { - TestBed.configureTestingModule({ - imports: [ - HttpModule - ], - declarations: [ - AppComponent - ] - }).compileComponents(); - })); - - it('should create the app', async(() => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.debugElement.componentInstance; - expect(app).toBeTruthy(); - })); - });`, - 'e2e/app.e2e-spec.ts': ` - import { browser, element, by } from 'protractor'; - - describe('master-project App', function () { - it('should display asset contents', () => { - browser.get('/'); - element.all(by.css('app-root p')).then(function (assets) { - expect(assets.length).toBe(4); - expect(assets[0].getText()).toBe('folder-asset.txt'); - expect(assets[1].getText()).toBe('glob-asset.txt'); - expect(assets[2].getText()).toBe('output-asset.txt'); - expect(assets[3].getText()).toBe('node_modules-asset.txt'); - }); - }); - });`, - })) - .then(() => !ejected && ng('test', '--watch=false')) - .then(() => !ejected && ng('e2e', 'test-project-e2e')); -} diff --git a/tests/legacy-cli/e2e/tests/basic/build.ts b/tests/legacy-cli/e2e/tests/basic/build.ts deleted file mode 100644 index 4473ca62d94f..000000000000 --- a/tests/legacy-cli/e2e/tests/basic/build.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { expectFileToMatch, moveDirectory } from '../../utils/fs'; -import { ng } from '../../utils/process'; - - -export default async function() { - // Development build - await ng('build'); - await expectFileToMatch('dist/test-project/index.html', 'main.js'); - - - // Production build - await ng('build', '--prod'); - await expectFileToMatch('dist/test-project/index.html', /main\.[a-zA-Z0-9]{20}\.js/); - - // Store the production build for artifact storage on CircleCI - if (process.env['CIRCLECI']) { - await ng('build', '--prod', '--output-hashing=none'); - await moveDirectory('dist', '/tmp/dist'); - } -} diff --git a/tests/legacy-cli/e2e/tests/basic/e2e.ts b/tests/legacy-cli/e2e/tests/basic/e2e.ts deleted file mode 100644 index 1051adc17996..000000000000 --- a/tests/legacy-cli/e2e/tests/basic/e2e.ts +++ /dev/null @@ -1,76 +0,0 @@ -// TODO(architect): edit the architect config instead of the cli config. - -import { - ng, - npm, - execAndWaitForOutputToMatch, - killAllProcesses -} from '../../utils/process'; -import {updateJsonFile} from '../../utils/project'; -import {expectToFail} from '../../utils/utils'; -import {moveFile, copyFile, replaceInFile} from '../../utils/fs'; - -// tslint:disable:max-line-length -export default function () { - // Should fail without updated webdriver - return updateJsonFile('package.json', packageJson => { - // Add to npm scripts to make running the binary compatible with Windows - const scripts = packageJson['scripts']; - scripts['wd:clean'] = 'webdriver-manager clean'; - }) - .then(() => npm('run', 'wd:clean')) - .then(() => expectToFail(() => ng('e2e', 'test-project-e2e', '--no-webdriver-update', '--devServerTarget='))) - // Add back the pre-defined version of webdriver. This script is defined when making projects. - .then(() => npm('run', 'webdriver-update')) - // Should fail without serving - .then(() => expectToFail(() => ng('e2e', 'test-project-e2e', '--devServerTarget='))) - // These should work. - .then(() => ng('e2e', 'test-project-e2e')) - .then(() => ng('e2e', 'test-project-e2e', '--devServerTarget=test-project:serve:production')) - // Should accept different config file - .then(() => moveFile('./e2e/protractor.conf.js', - './e2e/renamed-protractor.conf.js')) - .then(() => ng('e2e', 'test-project-e2e', - '--protractorConfig=e2e/renamed-protractor.conf.js')) - .then(() => moveFile('./e2e/renamed-protractor.conf.js', './e2e/protractor.conf.js')) - // Should accept different multiple spec files - .then(() => moveFile('./e2e/src/app.e2e-spec.ts', - './e2e/src/renamed-app.e2e-spec.ts')) - .then(() => copyFile('./e2e/src/renamed-app.e2e-spec.ts', - './e2e/src/another-app.e2e-spec.ts')) - .then(() => ng('e2e', 'test-project-e2e', '--specs', './e2e/renamed-app.e2e-spec.ts', - '--specs', './e2e/another-app.e2e-spec.ts')) - // Rename the spec back to how it was. - .then(() => moveFile('./e2e/src/renamed-app.e2e-spec.ts', - './e2e/src/app.e2e-spec.ts')) - // Suites block need to be added in the protractor.conf.js file to test suites - .then(() => replaceInFile('e2e/protractor.conf.js', `allScriptsTimeout: 11000,`, - `allScriptsTimeout: 11000, - suites: { - app: './e2e/src/app.e2e-spec.ts' - }, - `)) - .then(() => ng('e2e', 'test-project-e2e', '--suite=app')) - // Remove suites block from protractor.conf.js file after testing suites - .then(() => replaceInFile('e2e/protractor.conf.js', `allScriptsTimeout: 11000, - suites: { - app: './e2e/src/app.e2e-spec.ts' - }, - `, `allScriptsTimeout: 11000,` - )) - // Should start up Element Explorer - .then(() => execAndWaitForOutputToMatch('ng', ['e2e', 'test-project-e2e', '--element-explorer'], - /Element Explorer/)) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }) - // Should run side-by-side with `ng serve` - .then(() => execAndWaitForOutputToMatch('ng', ['serve'], - /: Compiled successfully./)) - .then(() => ng('e2e', 'test-project-e2e', '--devServerTarget=')) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }); -} diff --git a/tests/legacy-cli/e2e/tests/basic/in-project-logic.ts b/tests/legacy-cli/e2e/tests/basic/in-project-logic.ts deleted file mode 100644 index 5bf34c937a61..000000000000 --- a/tests/legacy-cli/e2e/tests/basic/in-project-logic.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as os from 'os'; -import { join } from 'path'; -import { writeFile, deleteFile } from '../../utils/fs'; -import { ng } from '../../utils/process'; -import { expectToFail } from '../../utils/utils'; - - -export default function() { - const homedir = os.homedir(); - const globalConfigPath = join(homedir, '.angular-config.json'); - return Promise.resolve() - .then(() => writeFile(globalConfigPath, '{"version":1}')) - .then(() => process.chdir(homedir)) - .then(() => ng('new', 'proj-name', '--dry-run')) - .then(() => deleteFile(globalConfigPath)) - // Test that we cannot create a project inside another project. - .then(() => writeFile(join(homedir, '.angular.json'), '{"version":1}')) - .then(() => expectToFail(() => ng('new', 'proj-name', '--dry-run'))) - .then(() => deleteFile(join(homedir, '.angular.json'))); -} diff --git a/tests/legacy-cli/e2e/tests/basic/rebuild.ts b/tests/legacy-cli/e2e/tests/basic/rebuild.ts deleted file mode 100644 index 6eda126667ad..000000000000 --- a/tests/legacy-cli/e2e/tests/basic/rebuild.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - killAllProcesses, - waitForAnyProcessOutputToMatch, - execAndWaitForOutputToMatch, - ng, -} from '../../utils/process'; -import {writeFile, writeMultipleFiles} from '../../utils/fs'; -import {wait} from '../../utils/utils'; -import {request} from '../../utils/http'; -import {getGlobalVariable} from '../../utils/env'; - -const validBundleRegEx = /: Compiled successfully./; - -export default function() { - if (process.platform.startsWith('win')) { - return Promise.resolve(); - } - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - const lazyChunkRegExp = /lazy-module\.js/g; - - return execAndWaitForOutputToMatch('ng', ['serve'], validBundleRegEx) - // Add a lazy module. - .then(() => ng('generate', 'module', 'lazy', '--routing')) - // Should trigger a rebuild with a new bundle. - // We need to use Promise.all to ensure we are waiting for the rebuild just before we write - // the file, otherwise rebuilds can be too fast and fail CI. - .then(() => Promise.all([ - waitForAnyProcessOutputToMatch(validBundleRegEx, 10000), - writeFile('src/app/app.module.ts', ` - import { BrowserModule } from '@angular/platform-browser'; - import { NgModule } from '@angular/core'; - import { FormsModule } from '@angular/forms'; - import { HttpClientModule } from '@angular/common/http'; - - import { AppComponent } from './app.component'; - import { RouterModule } from '@angular/router'; - - @NgModule({ - declarations: [ - AppComponent - ], - imports: [ - BrowserModule, - FormsModule, - HttpClientModule, - RouterModule.forRoot([ - { path: 'lazy', loadChildren: './lazy/lazy.module#LazyModule' } - ]) - ], - providers: [], - bootstrap: [AppComponent] - }) - export class AppModule { } - `) - ])) - // Count the bundles. - .then((results) => { - const stdout = results[0].stdout; - if (!lazyChunkRegExp.test(stdout)) { - throw new Error('Expected webpack to create a new chunk, but did not.'); - } - }) - // Change multiple files and check that all of them are invalidated and recompiled. - .then(() => Promise.all([ - waitForAnyProcessOutputToMatch(validBundleRegEx, 10000), - writeMultipleFiles({ - 'src/app/app.module.ts': ` - import { BrowserModule } from '@angular/platform-browser'; - import { NgModule } from '@angular/core'; - - import { AppComponent } from './app.component'; - - @NgModule({ - declarations: [ - AppComponent - ], - imports: [ - BrowserModule - ], - providers: [], - bootstrap: [AppComponent] - }) - export class AppModule { } - - console.log('$$_E2E_GOLDEN_VALUE_1'); - export let X = '$$_E2E_GOLDEN_VALUE_2'; - `, - 'src/main.ts': ` - import { enableProdMode } from '@angular/core'; - import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; - - import { AppModule } from './app/app.module'; - import { environment } from './environments/environment'; - - if (environment.production) { - enableProdMode(); - } - - platformBrowserDynamic().bootstrapModule(AppModule); - - import * as m from './app/app.module'; - console.log(m.X); - console.log('$$_E2E_GOLDEN_VALUE_3'); - ` - }) - ])) - .then(() => wait(2000)) - .then(() => request('http://localhost:4200/main.js')) - .then((body) => { - if (!body.match(/\$\$_E2E_GOLDEN_VALUE_1/)) { - throw new Error('Expected golden value 1.'); - } - if (!body.match(/\$\$_E2E_GOLDEN_VALUE_2/)) { - throw new Error('Expected golden value 2.'); - } - if (!body.match(/\$\$_E2E_GOLDEN_VALUE_3/)) { - throw new Error('Expected golden value 3.'); - } - }) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }); -} diff --git a/tests/legacy-cli/e2e/tests/basic/scripts-array.ts b/tests/legacy-cli/e2e/tests/basic/scripts-array.ts deleted file mode 100644 index 2bea2dabfcd2..000000000000 --- a/tests/legacy-cli/e2e/tests/basic/scripts-array.ts +++ /dev/null @@ -1,94 +0,0 @@ -// TODO(architect): edit the architect config instead of the cli config. - -import { - writeMultipleFiles, - expectFileToMatch, - appendToFile, - expectFileMatchToExist -} from '../../utils/fs'; -import { ng } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; -import { oneLineTrim } from 'common-tags'; -import * as fs from 'fs'; -import * as path from 'path'; - -// tslint:disable:max-line-length -export default function () { - return writeMultipleFiles({ - 'src/string-script.js': 'console.log(\'string-script\'); var number = 1+1;', - 'src/zstring-script.js': 'console.log(\'zstring-script\');', - 'src/fstring-script.js': 'console.log(\'fstring-script\');', - 'src/ustring-script.js': 'console.log(\'ustring-script\');', - 'src/bstring-script.js': 'console.log(\'bstring-script\');', - 'src/astring-script.js': 'console.log(\'astring-script\');', - 'src/cstring-script.js': 'console.log(\'cstring-script\');', - 'src/input-script.js': 'console.log(\'input-script\');', - 'src/lazy-script.js': 'console.log(\'lazy-script\');', - 'src/pre-rename-script.js': 'console.log(\'pre-rename-script\');', - 'src/pre-rename-lazy-script.js': 'console.log(\'pre-rename-lazy-script\');', - }) - .then(() => appendToFile('src/main.ts', 'import \'./string-script.js\';')) - .then(() => updateJsonFile('angular.json', configJson => { - const appArchitect = configJson.projects['test-project'].architect; - appArchitect.build.options.scripts = [ - { input: 'src/string-script.js' }, - { input: 'src/zstring-script.js' }, - { input: 'src/fstring-script.js' }, - { input: 'src/ustring-script.js' }, - { input: 'src/bstring-script.js' }, - { input: 'src/astring-script.js' }, - { input: 'src/cstring-script.js' }, - { input: 'src/input-script.js' }, - { input: 'src/lazy-script.js', lazy: true }, - { input: 'src/pre-rename-script.js', bundleName: 'renamed-script' }, - { input: 'src/pre-rename-lazy-script.js', bundleName: 'renamed-lazy-script', lazy: true } - ]; - })) - .then(() => ng('build', '--extract-css')) - // files were created successfully - .then(() => expectFileToMatch('dist/test-project/scripts.js', 'string-script')) - .then(() => expectFileToMatch('dist/test-project/scripts.js', 'input-script')) - .then(() => expectFileToMatch('dist/test-project/lazy-script.js', 'lazy-script')) - .then(() => expectFileToMatch('dist/test-project/renamed-script.js', 'pre-rename-script')) - .then(() => expectFileToMatch('dist/test-project/renamed-lazy-script.js', 'pre-rename-lazy-script')) - // index.html lists the right bundles - .then(() => expectFileToMatch('dist/test-project/index.html', oneLineTrim` - - - - - - - `)) - // Ensure scripts can be separately imported from the app. - .then(() => expectFileToMatch('dist/test-project/main.js', 'console.log(\'string-script\');')); - // TODO(architect): disabled until --prod is added. - // Verify uglify, sourcemaps and hashes. Lazy scripts should not get hashes. - // .then(() => ng('build', '--prod', '--source-map')) - // .then(() => expectFileMatchToExist('dist', /scripts\.[0-9a-f]{20}\.js/)) - // .then(fileName => expectFileToMatch(`dist/${fileName}`, 'var number=2;')) - // .then(() => expectFileMatchToExist('dist', /scripts\.[0-9a-f]{20}\.js\.map/)) - // .then(() => expectFileMatchToExist('dist', /renamed-script\.[0-9a-f]{20}\.js/)) - // .then(() => expectFileMatchToExist('dist', /renamed-script\.[0-9a-f]{20}\.js.map/)) - // .then(() => expectFileToMatch('dist/test-project/lazy-script.js', 'lazy-script')) - // .then(() => expectFileToMatch('dist/test-project/enamed-lazy-script.js', 'pre-rename-lazy-script')) - - // // Expect order to be preserved. - // .then(() => { - // const [fileName] = fs.readdirSync('dist') - // .filter(name => name.match(/^scripts\..*\.js$/)); - - // const content = fs.readFileSync(path.join('dist', fileName), 'utf-8'); - // const re = new RegExp(/['"]string-script['"].*/.source - // + /['"]zstring-script['"].*/.source - // + /['"]fstring-script['"].*/.source - // + /['"]ustring-script['"].*/.source - // + /['"]bstring-script['"].*/.source - // + /['"]astring-script['"].*/.source - // + /['"]cstring-script['"].*/.source - // + /['"]input-script['"]/.source); - // if (!content.match(re)) { - // throw new Error('Scripts are not included in order.'); - // } - // }); -} diff --git a/tests/legacy-cli/e2e/tests/basic/styles-array.ts b/tests/legacy-cli/e2e/tests/basic/styles-array.ts deleted file mode 100644 index 4815c503d12b..000000000000 --- a/tests/legacy-cli/e2e/tests/basic/styles-array.ts +++ /dev/null @@ -1,50 +0,0 @@ -// TODO(architect): edit the architect config instead of the cli config. - -import { - writeMultipleFiles, - expectFileToMatch -} from '../../utils/fs'; -import { ng } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; -import { oneLineTrim } from 'common-tags'; - -export default function () { - return writeMultipleFiles({ - 'src/string-style.css': '.string-style { color: red }', - 'src/input-style.css': '.input-style { color: red }', - 'src/lazy-style.css': '.lazy-style { color: red }', - 'src/pre-rename-style.css': '.pre-rename-style { color: red }', - 'src/pre-rename-lazy-style.css': '.pre-rename-lazy-style { color: red }', - }) - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.styles = [ - { input: 'src/string-style.css' }, - { input: 'src/input-style.css' }, - { input: 'src/lazy-style.css', lazy: true }, - { input: 'src/pre-rename-style.css', bundleName: 'renamed-style' }, - { input: 'src/pre-rename-lazy-style.css', bundleName: 'renamed-lazy-style', lazy: true } - ]; - })) - .then(() => ng('build', '--extract-css')) - // files were created successfully - .then(() => expectFileToMatch('dist/test-project/styles.css', '.string-style')) - .then(() => expectFileToMatch('dist/test-project/styles.css', '.input-style')) - .then(() => expectFileToMatch('dist/test-project/lazy-style.css', '.lazy-style')) - .then(() => expectFileToMatch('dist/test-project/renamed-style.css', '.pre-rename-style')) - .then(() => expectFileToMatch( - 'dist/test-project/renamed-lazy-style.css', - '.pre-rename-lazy-style', - )) - // index.html lists the right bundles - .then(() => expectFileToMatch('dist/test-project/index.html', oneLineTrim` - - - `)) - .then(() => expectFileToMatch('dist/test-project/index.html', oneLineTrim` - - - - - `)); -} diff --git a/tests/legacy-cli/e2e/tests/basic/test.ts b/tests/legacy-cli/e2e/tests/basic/test.ts deleted file mode 100644 index c95a026b867c..000000000000 --- a/tests/legacy-cli/e2e/tests/basic/test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ng } from '../../utils/process'; -import { moveFile } from '../../utils/fs'; - -// tslint:disable:max-line-length -export default function () { - // make sure both --watch=false work - return ng('test', '--watch=false') - .then(() => moveFile('./src/karma.conf.js', - './src/karma.conf.bis.js')) - .then(() => ng('test', '--watch=false', '--karmaConfig=src/karma.conf.bis.js')); -} diff --git a/tests/legacy-cli/e2e/tests/basic/update-1.0.ts b/tests/legacy-cli/e2e/tests/basic/update-1.0.ts deleted file mode 100644 index 1dcfd03dcaab..000000000000 --- a/tests/legacy-cli/e2e/tests/basic/update-1.0.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createProjectFromAsset } from '../../utils/assets'; -import { ng, silentNpm } from '../../utils/process'; -import { useBuiltPackages, useCIChrome, useCIDefaults } from '../../utils/project'; -import { expectToFail } from '../../utils/utils'; - - -export default function () { - return Promise.resolve() - .then(() => createProjectFromAsset('1.0-project')) - .then(() => useCIChrome('.')) - .then(() => expectToFail(() => ng('build'))) - .then(() => ng('update', '@angular/cli')) - .then(() => useBuiltPackages()) - .then(() => silentNpm('install')) - .then(() => useCIDefaults('one-oh-project')) - .then(() => ng('generate', 'component', 'my-comp')) - .then(() => ng('test', '--watch=false')) - .then(() => ng('lint')) - .then(() => ng('build')) - .then(() => ng('build', '--prod')) - .then(() => ng('e2e')); -} diff --git a/tests/legacy-cli/e2e/tests/basic/update-1.7-longhand.ts b/tests/legacy-cli/e2e/tests/basic/update-1.7-longhand.ts deleted file mode 100644 index 50317dcd31ed..000000000000 --- a/tests/legacy-cli/e2e/tests/basic/update-1.7-longhand.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createProjectFromAsset } from '../../utils/assets'; -import { ng, silentNpm } from '../../utils/process'; -import { useBuiltPackages } from '../../utils/project'; -import { expectToFail } from '../../utils/utils'; - - -export default function () { - return Promise.resolve() - .then(() => createProjectFromAsset('1.7-project')) - .then(() => expectToFail(() => ng('build'))) - .then(() => ng('update', '@angular/cli', '--migrate-only', '--from=1.7.1')) - .then(() => useBuiltPackages()) - .then(() => silentNpm('install')) - .then(() => ng('build')); -} diff --git a/tests/legacy-cli/e2e/tests/basic/update-1.7.ts b/tests/legacy-cli/e2e/tests/basic/update-1.7.ts deleted file mode 100644 index 016cf4aeba39..000000000000 --- a/tests/legacy-cli/e2e/tests/basic/update-1.7.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createProjectFromAsset } from '../../utils/assets'; -import { ng, silentNpm } from '../../utils/process'; -import { useBuiltPackages, useCIChrome, useCIDefaults } from '../../utils/project'; -import { expectToFail } from '../../utils/utils'; - - -export default function () { - return Promise.resolve() - .then(() => createProjectFromAsset('1.7-project')) - .then(() => useCIChrome('.')) - .then(() => expectToFail(() => ng('build'))) - .then(() => ng('update', '@angular/cli')) - .then(() => useBuiltPackages()) - .then(() => silentNpm('install')) - .then(() => useCIDefaults('latest-project')) - .then(() => ng('generate', 'component', 'my-comp')) - .then(() => ng('test', '--watch=false')) - .then(() => ng('lint')) - .then(() => ng('build', '--prod')) - .then(() => ng('e2e')); -} diff --git a/tests/legacy-cli/e2e/tests/basic/update.ts b/tests/legacy-cli/e2e/tests/basic/update.ts deleted file mode 100644 index 2ed02ee65856..000000000000 --- a/tests/legacy-cli/e2e/tests/basic/update.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ng, silentNpm } from '../../utils/process'; -import { readFile, writeFile } from '../../utils/fs'; -import { updateJsonFile } from '../../utils/project'; - - -export default function () { - // Disable this e2e as it takes about 10 minutes on a good computer and is flaky. - if (!process.env['E2E_UPDATE']) { - return Promise.resolve(); - } - - let origPackageJson: string; - let origCoreVersion: string; - let origCliVersion: string; - let origTypeScriptVersion: string; - - function updateVersions(obj: any) { - const keys = Object.keys(obj); - keys.forEach(key => { - if (key.startsWith('@angular/')) { - obj[key] = '2.1.0'; - } - }); - } - - return readFile('package.json') - .then((content) => origPackageJson = content) - .then(() => updateJsonFile('package.json', obj => { - origCoreVersion = obj.dependencies['@angular/core']; - origCliVersion = obj.devDependencies['@angular/cli']; - origTypeScriptVersion = obj.devDependencies['typescript']; - obj.devDependencies['@angular/cli'] = '1.6.5'; - obj.devDependencies['typescript'] = '2.0.2'; - obj.dependencies['rxjs'] = '5.0.0-beta.12'; - })) - .then(() => ng('update', '@angular/cli', 'typescript')) - .then(() => readFile('package.json')) - .then(s => { - const obj = JSON.parse(s); - if (origCliVersion === obj.devDependencies['@angular/cli']) { - throw new Error('CLI version not updated'); - } - if (origTypeScriptVersion === obj.devDependencies['typescript']) { - throw new Error('TypeScript version not updated'); - } - }) - .then(() => writeFile('package.json', origPackageJson)) - .then(() => silentNpm('install')); -} diff --git a/tests/legacy-cli/e2e/tests/build/allow-js.ts b/tests/legacy-cli/e2e/tests/build/allow-js.ts deleted file mode 100644 index 2cdf49d5352f..000000000000 --- a/tests/legacy-cli/e2e/tests/build/allow-js.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ng } from '../../utils/process'; -import { updateTsConfig } from '../../utils/project'; -import { appendToFile, writeFile } from '../../utils/fs'; - -export default async function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - await writeFile('src/my-js-file.js', 'console.log(1); export const a = 2;'); - await appendToFile('src/main.ts', ` - import { a } from './my-js-file'; - console.log(a); - `); - - await updateTsConfig(json => { - json['compilerOptions'].allowJs = true; - }); - - await ng('build'); - await ng('build', '--aot'); -} diff --git a/tests/legacy-cli/e2e/tests/build/aot/aot-decorators.ts b/tests/legacy-cli/e2e/tests/build/aot/aot-decorators.ts deleted file mode 100644 index c6d56342e2b0..000000000000 --- a/tests/legacy-cli/e2e/tests/build/aot/aot-decorators.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {ng} from '../../../utils/process'; -import {appendToFile, expectFileToMatch, prependToFile, replaceInFile} from '../../../utils/fs'; -import {expectToFail} from '../../../utils/utils'; -import {ngVersionMatches} from '../../../utils/version'; - -export default function() { - // TODO(architect): This behaviour seems to have changed in devkit/build-angular. Figure out why. - return; - - return ng('generate', 'component', 'test-component', '--module', 'app.module.ts') - .then(() => prependToFile('src/app/test-component/test-component.component.ts', ` - import { Optional, SkipSelf } from '@angular/core'; - `)) - .then(() => replaceInFile('src/app/test-component/test-component.component.ts', - /constructor.*/, ` - constructor(@Optional() @SkipSelf() public test: TestComponentComponent) { - console.log(test); - } - `)) - .then(() => appendToFile('src/app/app.component.html', ` - - `)) - .then(() => ng('build', '--aot')) - .then(() => expectToFail(() => expectFileToMatch('dist/test-project/main.js', /\bComponent\b/))) - // Check that the decorators are still kept. - .then(() => expectFileToMatch('dist/test-project/main.js', /ctorParameters.*Optional.*SkipSelf/)) - .then(() => expectToFail(() => expectFileToMatch('dist/test-project/main.js', /\bNgModule\b/))); -} diff --git a/tests/legacy-cli/e2e/tests/build/aot/aot-i18n.ts b/tests/legacy-cli/e2e/tests/build/aot/aot-i18n.ts deleted file mode 100644 index 34f7b39ea5a6..000000000000 --- a/tests/legacy-cli/e2e/tests/build/aot/aot-i18n.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ng } from '../../../utils/process'; -import { expectFileToMatch, writeFile, createDir, appendToFile, readFile } from '../../../utils/fs'; -import { expectToFail } from '../../../utils/utils'; -import { Version } from '../../../../../packages/@angular/cli/upgrade/version'; -import { SemVer } from 'semver'; - -// tslint:disable:max-line-length -export default function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return Promise.resolve() - .then(() => createDir('src/locale')) - .then(() => writeFile('src/locale/messages.fr.xlf', ` - - - - - - Hello i18n! - Bonjour i18n! - An introduction header for this sample - - - - `)) - .then(() => appendToFile('src/app/app.component.html', - '

Hello i18n!

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

Other content

')) - .then(() => ng('build', '--aot', '--i18nFile', 'src/locale/messages.fr.xlf', '--i18nFormat', - 'xlf', '--i18n-locale', 'fr', '--i18n-missing-translation', 'ignore')) - .then(() => expectFileToMatch('dist/test-project/main.js', /Other content/)) - .then(() => expectToFail(() => ng('build', '--aot', '--i18nFile', 'src/locale/messages.fr.xlf', - '--i18nFormat', 'xlf', '--i18n-locale', 'fr', '--i18n-missing-translation', 'error'))); -} diff --git a/tests/legacy-cli/e2e/tests/build/barrel-file.ts b/tests/legacy-cli/e2e/tests/build/barrel-file.ts deleted file mode 100644 index b39480f308ec..000000000000 --- a/tests/legacy-cli/e2e/tests/build/barrel-file.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { replaceInFile, writeFile } from '../../utils/fs'; -import { ng } from '../../utils/process'; - -export default async function() { - await writeFile('src/app/index.ts', `export { AppModule } from './app.module';`); - await replaceInFile('src/main.ts', './app/app.module', './app'); - await ng('build'); -} diff --git a/tests/legacy-cli/e2e/tests/build/base-href.ts b/tests/legacy-cli/e2e/tests/build/base-href.ts deleted file mode 100644 index e8a5769448f3..000000000000 --- a/tests/legacy-cli/e2e/tests/build/base-href.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ng } from '../../utils/process'; -import { expectFileToMatch } from '../../utils/fs'; - - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return ng('build', '--base-href', '/myUrl') - .then(() => expectFileToMatch('dist/test-project/index.html', //)); -} diff --git a/tests/legacy-cli/e2e/tests/build/build-app-shell-with-schematic.ts b/tests/legacy-cli/e2e/tests/build/build-app-shell-with-schematic.ts deleted file mode 100644 index 85bef90848ed..000000000000 --- a/tests/legacy-cli/e2e/tests/build/build-app-shell-with-schematic.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ng, npm } from '../../utils/process'; -import { expectFileToMatch, appendToFile } from '../../utils/fs'; -import { getGlobalVariable } from '../../utils/env'; -import { expectToFail } from '../../utils/utils'; -import { updateJsonFile } from '../../utils/project'; -import { readNgVersion } from '../../utils/version'; - - -export default function () { - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - // Skip this test in Angular 2/4. - if (getGlobalVariable('argv').ng2 || getGlobalVariable('argv').ng4) { - return Promise.resolve(); - } - - let platformServerVersion = readNgVersion(); - let httpVersion = readNgVersion(); - - if (getGlobalVariable('argv')['ng-snapshots']) { - platformServerVersion = 'github:angular/platform-server-builds'; - httpVersion = 'github:angular/http-builds'; - } - - - return Promise.resolve() - .then(() => expectToFail(() => { - return ng('generate', 'appShell', '--universal-app', 'universal'); - }) - .then(() => appendToFile('src/app/app.component.html', '')) - .then(() => ng('generate', 'appShell', '--universal-app', 'universal')) - .then(() => updateJsonFile('package.json', packageJson => { - const dependencies = packageJson['dependencies']; - dependencies['@angular/platform-server'] = platformServerVersion; - // ServerModule depends on @angular/http regardless the app's dependency. - dependencies['@angular/http'] = httpVersion; - }) - .then(() => npm('install')) - .then(() => ng('build', '--optimization')) - .then(() => expectFileToMatch('dist/test-project/index.html', /app-shell works!/)) - .then(() => ng('build', '--optimization', '--skip-app-shell')) - .then(() => expectToFail(() => expectFileToMatch('dist/test-project/index.html', /app-shell works!/))); -} diff --git a/tests/legacy-cli/e2e/tests/build/build-app-shell.ts b/tests/legacy-cli/e2e/tests/build/build-app-shell.ts deleted file mode 100644 index a2fac7d400ef..000000000000 --- a/tests/legacy-cli/e2e/tests/build/build-app-shell.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { ng, npm } from '../../utils/process'; -import { expectFileToMatch, writeFile } from '../../utils/fs'; -import { getGlobalVariable } from '../../utils/env'; -import { expectToFail } from '../../utils/utils'; -import { updateJsonFile } from '../../utils/project'; -import { readNgVersion } from '../../utils/version'; -import { stripIndent } from 'common-tags'; - - -export default function () { - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - // Skip this test in Angular 2/4. - if (getGlobalVariable('argv').ng2 || getGlobalVariable('argv').ng4) { - return Promise.resolve(); - } - - let platformServerVersion = readNgVersion(); - let httpVersion = readNgVersion(); - - if (getGlobalVariable('argv')['ng-snapshots']) { - platformServerVersion = 'github:angular/platform-server-builds'; - httpVersion = 'github:angular/http-builds'; - } - - return Promise.resolve() - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect['server'] = { - builder: '@angular-devkit/build-angular:server', - options: { - outputPath: 'dist/test-project-server', - main: 'src/main.server.ts', - tsConfig: 'src/tsconfig.server.json' - } - }; - appArchitect['app-shell'] = { - builder: '@angular-devkit/build-angular:app-shell', - options: { - browserTarget: 'test-project:build:production', - serverTarget: 'test-project:server', - route: '/shell' - } - }; - })) - .then(() => writeFile('./src/tsconfig.server.json', ` - { - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../dist-server", - "baseUrl": "./", - "module": "commonjs", - "types": [] - }, - "exclude": [ - "test.ts", - "**/*.spec.ts" - ], - "angularCompilerOptions": { - "entryModule": "app/app.server.module#AppServerModule" - } - } - `)) - .then(() => writeFile('./src/main.server.ts', ` - import { enableProdMode } from '@angular/core'; - - import { environment } from './environments/environment'; - - if (environment.production) { - enableProdMode(); - } - - export { AppServerModule } from './app/app.server.module'; - `)) - .then(() => writeFile('./src/app/app.component.html', stripIndent` - Hello World - - `)) - .then(() => writeFile('./src/app/app.module.ts', stripIndent` - import { BrowserModule } from '@angular/platform-browser'; - import { NgModule } from '@angular/core'; - import { RouterModule } from '@angular/router'; - - import { AppComponent } from './app.component'; - - @NgModule({ - imports: [ - BrowserModule.withServerTransition({ appId: 'appshell-play' }), - RouterModule - ], - declarations: [AppComponent], - bootstrap: [AppComponent] - }) - export class AppModule { } - `)) - .then(() => writeFile('./src/app/app.server.module.ts', stripIndent` - import {NgModule} from '@angular/core'; - import {ServerModule} from '@angular/platform-server'; - import { Routes, RouterModule } from '@angular/router'; - - import { AppModule } from './app.module'; - import { AppComponent } from './app.component'; - import { ShellComponent } from './shell.component'; - - const routes: Routes = [ - { path: 'shell', component: ShellComponent } - ]; - - @NgModule({ - imports: [ - // The AppServerModule should import your AppModule followed - // by the ServerModule from @angular/platform-server. - AppModule, - ServerModule, - RouterModule.forRoot(routes), - ], - // Since the bootstrapped component is not inherited from your - // imported AppModule, it needs to be repeated here. - bootstrap: [AppComponent], - declarations: [ShellComponent], - }) - export class AppServerModule {} - `)) - .then(() => writeFile('./src/app/shell.component.ts', stripIndent` - import { Component } from '@angular/core'; - @Component({ - selector: 'app-shell', - template: '

shell Works!

', - styles: [] - }) - export class ShellComponent {} - `)) - .then(() => updateJsonFile('package.json', packageJson => { - const dependencies = packageJson['dependencies']; - dependencies['@angular/platform-server'] = platformServerVersion; - // ServerModule depends on @angular/http regardless the app's dependency. - dependencies['@angular/http'] = httpVersion; - }) - .then(() => npm('install'))) - .then(() => ng('run', 'test-project:app-shell')) - .then(() => expectFileToMatch('dist/test-project/index.html', /shell Works!/)); -} diff --git a/tests/legacy-cli/e2e/tests/build/build-errors.ts b/tests/legacy-cli/e2e/tests/build/build-errors.ts deleted file mode 100644 index bf66ca0b95bb..000000000000 --- a/tests/legacy-cli/e2e/tests/build/build-errors.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { ng } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; -import { writeFile, appendToFile, readFile, replaceInFile } from '../../utils/fs'; -import { getGlobalVariable } from '../../utils/env'; -import { expectToFail } from '../../utils/utils'; - -// tslint:disable:max-line-length - -const extraErrors = [ - `Final loader didn't return a Buffer or String`, - `doesn't contain a valid alias configuration`, - `main.ts is not part of the TypeScript compilation.`, -]; - -export default function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - if (process.platform.startsWith('win')) { - return Promise.resolve(); - } - - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - // Skip this test in Angular 2/4. - if (getGlobalVariable('argv').ng2 || getGlobalVariable('argv').ng4) { - return Promise.resolve(); - } - - let origContent: string; - - return Promise.resolve() - // Save the original contents of `./src/app/app.component.ts`. - .then(() => readFile('./src/app/app.component.ts')) - .then((contents) => origContent = contents) - // Check `part of the TypeScript compilation` errors. - // These should show an error only for the missing file. - .then(() => updateJsonFile('./src/tsconfig.app.json', configJson => { - configJson.files = ['main.ts']; - })) - .then(() => expectToFail(() => ng('build'))) - .then(({ message }) => { - if (!message.includes('polyfills.ts is missing from the TypeScript compilation')) { - throw new Error(`Expected missing TS file error, got this instead:\n${message}`); - } - if (extraErrors.some((e) => message.includes(e))) { - throw new Error(`Did not expect extra errors but got:\n${message}`); - } - }) - .then(() => updateJsonFile('./src/tsconfig.app.json', configJson => { - configJson.files = undefined; - })) - // Check simple single syntax errors. - // These shouldn't skip emit and just show a TS error. - .then(() => appendToFile('./src/app/app.component.ts', ']]]')) - .then(() => expectToFail(() => ng('build'))) - .then(({ message }) => { - if (!message.includes('Declaration or statement expected.')) { - throw new Error(`Expected syntax error, got this instead:\n${message}`); - } - if (extraErrors.some((e) => message.includes(e))) { - throw new Error(`Did not expect extra errors but got:\n${message}`); - } - }) - .then(() => writeFile('./src/app/app.component.ts', origContent)) - // Check errors when files were not emitted due to static analysis errors. - .then(() => replaceInFile('./src/app/app.component.ts', `'app-root'`, `(() => 'app-root')()`)) - .then(() => expectToFail(() => ng('build', '--aot'))) - .then(({ message }) => { - if (!message.includes('Function calls are not supported') - && !message.includes('Function expressions are not supported in decorators')) { - throw new Error(`Expected static analysis error, got this instead:\n${message}`); - } - if (extraErrors.some((e) => message.includes(e))) { - throw new Error(`Did not expect extra errors but got:\n${message}`); - } - }) - .then(() => writeFile('./src/app/app.component.ts', origContent)); -} diff --git a/tests/legacy-cli/e2e/tests/build/build-optimizer.ts b/tests/legacy-cli/e2e/tests/build/build-optimizer.ts deleted file mode 100644 index 365c0e3d1067..000000000000 --- a/tests/legacy-cli/e2e/tests/build/build-optimizer.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ng } from '../../utils/process'; -import { expectFileToMatch, expectFileToExist } from '../../utils/fs'; -import { expectToFail } from '../../utils/utils'; - - -export default function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return ng('build', '--aot', '--build-optimizer') - .then(() => expectToFail(() => expectFileToMatch('dist/test-project/main.js', /\.decorators =/))) - .then(() => ng('build', '--prod')) - .then(() => expectToFail(() => expectFileToExist('dist/vendor.js'))) - .then(() => expectToFail(() => expectFileToMatch('dist/test-project/main.js', /\.decorators =/))) - .then(() => expectToFail(() => ng('build', '--aot=false', '--build-optimizer'))); -} diff --git a/tests/legacy-cli/e2e/tests/build/bundle-budgets.ts b/tests/legacy-cli/e2e/tests/build/bundle-budgets.ts deleted file mode 100644 index 974a4b189d22..000000000000 --- a/tests/legacy-cli/e2e/tests/build/bundle-budgets.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @license - * 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 - */ - -import { getGlobalVariable } from '../../utils/env'; -import { ng } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; -import { expectToFail } from '../../utils/utils'; - -// tslint:disable:max-line-length -export default function () { - const budgetConfigs = [ - { - expectation: 'pass', - message: 'BIG max for all, should not error', - budget: { type: 'allScript', maximumError: '100mb' }, - }, - { - expectation: 'error', - message: 'Budget error: all, max error', - budget: { type: 'all', maximumError: '100b' }, - }, - { - expectation: 'warning', - message: 'Budget warning: all, min warning', - budget: { type: 'all', minimumWarning: '100mb' }, - }, - ]; - - const promiseFactories = budgetConfigs.map(cfg => { - if (cfg.expectation === 'error') { - return () => { - return updateJsonFile('angular.json', (json) => { - json.projects['test-project'].architect.build.options.budgets = [cfg.budget]; - }) - .then(() => expectToFail(() => ng('build', '--optimization'))) - .then(errorMessage => { - if (!/ERROR in budgets/.test(errorMessage)) { - throw new Error(cfg.message); - } - }); - }; - } else if (cfg.expectation === 'warning') { - return () => { - return updateJsonFile('angular.json', (json) => { - json.projects['test-project'].architect.build.options.budgets = [cfg.budget]; - }) - .then(() => ng('build', '--optimization')) - .then(({ stdout }) => { - if (!/WARNING in budgets/.test(stdout)) { - throw new Error(cfg.message); - } - }); - }; - } else { // pass - return () => { - return updateJsonFile('angular.json', (json) => { - json.projects['test-project'].architect.build.options.budgets = [cfg.budget]; - }) - .then(() => ng('build', '--optimization')) - .then(({ stdout }) => { - if (/(WARNING|ERROR)/.test(stdout)) { - throw new Error(cfg.message); - } - }); - }; - } - }); - - let promiseChain = Promise.resolve(); - for (let i = 0; i < promiseFactories.length; i++) { - promiseChain = promiseChain.then(promiseFactories[i]); - } - - return promiseChain; -} diff --git a/tests/legacy-cli/e2e/tests/build/chunk-hash.ts b/tests/legacy-cli/e2e/tests/build/chunk-hash.ts deleted file mode 100644 index b575305afb6c..000000000000 --- a/tests/legacy-cli/e2e/tests/build/chunk-hash.ts +++ /dev/null @@ -1,105 +0,0 @@ -import * as fs from 'fs'; - -import {ng} from '../../utils/process'; -import {writeFile, prependToFile, replaceInFile} from '../../utils/fs'; - -const OUTPUT_RE = /(main|polyfills|vendor|inline|styles|\d+)\.[a-z0-9]+\.(chunk|bundle)\.(js|css)$/; - -function generateFileHashMap(): Map { - const hashes = new Map(); - - fs.readdirSync('./dist') - .forEach(name => { - if (!name.match(OUTPUT_RE)) { - return; - } - - const [module, hash] = name.split('.'); - hashes.set(module, hash); - }); - - return hashes; -} - -function validateHashes( - oldHashes: Map, - newHashes: Map, - shouldChange: Array): void { - - console.log(' Validating hashes...'); - console.log(` Old hashes: ${JSON.stringify([...oldHashes])}`); - console.log(` New hashes: ${JSON.stringify([...newHashes])}`); - - oldHashes.forEach((hash, module) => { - if (hash == newHashes.get(module)) { - if (shouldChange.includes(module)) { - throw new Error(`Module "${module}" did not change hash (${hash})...`); - } - } else if (!shouldChange.includes(module)) { - throw new Error(`Module "${module}" changed hash (${hash})...`); - } - }); -} - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - return; - - let oldHashes: Map; - let newHashes: Map; - // First, collect the hashes. - return Promise.resolve() - .then(() => ng('generate', 'module', 'lazy', '--routing')) - .then(() => prependToFile('src/app/app.module.ts', ` - import { RouterModule } from '@angular/router'; - import { ReactiveFormsModule } from '@angular/forms'; - `)) - .then(() => replaceInFile('src/app/app.module.ts', 'imports: [', `imports: [ - RouterModule.forRoot([{ path: "lazy", loadChildren: "./lazy/lazy.module#LazyModule" }]), - ReactiveFormsModule, - `)) - .then(() => ng('build', '--output-hashing=all')) - .then(() => { - oldHashes = generateFileHashMap(); - }) - .then(() => ng('build', '--output-hashing=all')) - .then(() => { - newHashes = generateFileHashMap(); - }) - .then(() => { - validateHashes(oldHashes, newHashes, []); - oldHashes = newHashes; - }) - .then(() => writeFile('src/styles.css', 'body { background: blue; }')) - .then(() => ng('build', '--output-hashing=all')) - .then(() => { - newHashes = generateFileHashMap(); - }) - .then(() => { - validateHashes(oldHashes, newHashes, ['styles']); - oldHashes = newHashes; - }) - .then(() => writeFile('src/app/app.component.css', 'h1 { margin: 10px; }')) - .then(() => ng('build', '--output-hashing=all')) - .then(() => { - newHashes = generateFileHashMap(); - }) - .then(() => { - validateHashes(oldHashes, newHashes, ['main']); - oldHashes = newHashes; - }) - .then(() => prependToFile('src/app/lazy/lazy.module.ts', ` - import { ReactiveFormsModule } from '@angular/forms'; - `)) - .then(() => replaceInFile('src/app/lazy/lazy.module.ts', 'imports: [', ` - imports: [ - ReactiveFormsModule, - `)) - .then(() => ng('build', '--output-hashing=all')) - .then(() => { - newHashes = generateFileHashMap(); - }) - .then(() => { - validateHashes(oldHashes, newHashes, ['inline', '0']); - }); -} diff --git a/tests/legacy-cli/e2e/tests/build/config-file-fallback.ts b/tests/legacy-cli/e2e/tests/build/config-file-fallback.ts deleted file mode 100644 index 3dd987431b8a..000000000000 --- a/tests/legacy-cli/e2e/tests/build/config-file-fallback.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {ng} from '../../utils/process'; -import {moveFile} from '../../utils/fs'; - - -export default function() { - return Promise.resolve() - .then(() => ng('build')) - .then(() => moveFile('angular.json', '.angular.json')) - .then(() => ng('build')); -} diff --git a/tests/legacy-cli/e2e/tests/build/css-urls.ts b/tests/legacy-cli/e2e/tests/build/css-urls.ts deleted file mode 100644 index e626ba4e4108..000000000000 --- a/tests/legacy-cli/e2e/tests/build/css-urls.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { ng } from '../../utils/process'; -import { - expectFileToMatch, - expectFileToExist, - expectFileMatchToExist, - writeMultipleFiles -} from '../../utils/fs'; -import { copyProjectAsset } from '../../utils/assets'; -import { expectToFail } from '../../utils/utils'; - -const imgSvg = ` - - - -`; - -export default function () { - return Promise.resolve() - // Verify absolute/relative paths in global/component css. - .then(() => writeMultipleFiles({ - 'src/styles.css': ` - h1 { background: url('/assets/global-img-absolute.svg'); } - h2 { background: url('./assets/global-img-relative.png'); } - `, - 'src/app/app.component.css': ` - h3 { background: url('/assets/component-img-absolute.svg'); } - h4 { background: url('../assets/component-img-relative.png'); } - `, - 'src/assets/global-img-absolute.svg': imgSvg, - 'src/assets/component-img-absolute.svg': imgSvg - })) - // use image with file size >10KB to prevent inlining - .then(() => copyProjectAsset('images/spectrum.png', './src/assets/global-img-relative.png')) - .then(() => copyProjectAsset('images/spectrum.png', './src/assets/component-img-relative.png')) - .then(() => ng('build', '--extract-css', '--aot')) - // Check paths are correctly generated. - .then(() => expectFileToMatch('dist/test-project/styles.css', 'assets/global-img-absolute.svg')) - .then(() => expectFileToMatch('dist/test-project/styles.css', - /url\('\/assets\/global-img-absolute\.svg'\)/)) - .then(() => expectFileToMatch('dist/test-project/styles.css', - /global-img-relative\.png/)) - .then(() => expectFileToMatch('dist/test-project/main.js', - '/assets/component-img-absolute.svg')) - .then(() => expectFileToMatch('dist/test-project/main.js', - /component-img-relative\.png/)) - // Check files are correctly created. - .then(() => expectToFail(() => expectFileToExist('dist/test-project/global-img-absolute.svg'))) - .then(() => expectToFail(() => expectFileToExist('dist/test-project/component-img-absolute.svg'))) - .then(() => expectFileMatchToExist('./dist/test-project', /global-img-relative\.png/)) - .then(() => expectFileMatchToExist('./dist/test-project', /component-img-relative\.png/)) - // Check urls with deploy-url scheme are used as is. - .then(() => ng('build', '--base-href=/base/', '--deploy-url=http://deploy.url/', - '--extract-css')) - .then(() => expectFileToMatch('dist/test-project/styles.css', - /url\(\'http:\/\/deploy\.url\/assets\/global-img-absolute\.svg\'\)/)) - .then(() => expectFileToMatch('dist/test-project/main.js', - /url\(\'http:\/\/deploy\.url\/assets\/component-img-absolute\.svg\'\)/)) - // Check urls with base-href scheme are used as is (with deploy-url). - .then(() => ng('build', '--base-href=http://base.url/', '--deploy-url=deploy/', - '--extract-css')) - .then(() => expectFileToMatch('dist/test-project/styles.css', - /url\(\'http:\/\/base\.url\/deploy\/assets\/global-img-absolute\.svg\'\)/)) - .then(() => expectFileToMatch('dist/test-project/main.js', - /url\(\'http:\/\/base\.url\/deploy\/assets\/component-img-absolute\.svg\'\)/)) - // Check urls with deploy-url and base-href scheme only use deploy-url. - .then(() => ng('build', '--base-href=http://base.url/', '--deploy-url=http://deploy.url/', - '--extract-css')) - .then(() => expectFileToMatch('dist/test-project/styles.css', - /url\(\'http:\/\/deploy\.url\/assets\/global-img-absolute\.svg\'\)/)) - .then(() => expectFileToMatch('dist/test-project/main.js', - /url\(\'http:\/\/deploy\.url\/assets\/component-img-absolute\.svg\'\)/)) - // Check with base-href and deploy-url flags. - .then(() => ng('build', '--base-href=/base/', '--deploy-url=deploy/', - '--extract-css', '--aot')) - .then(() => expectFileToMatch('dist/test-project/styles.css', - '/base/deploy/assets/global-img-absolute.svg')) - .then(() => expectFileToMatch('dist/test-project/styles.css', - /global-img-relative\.png/)) - .then(() => expectFileToMatch('dist/test-project/main.js', - '/base/deploy/assets/component-img-absolute.svg')) - .then(() => expectFileToMatch('dist/test-project/main.js', - /deploy\/component-img-relative\.png/)) - // Check with identical base-href and deploy-url flags. - .then(() => ng('build', '--base-href=/base/', '--deploy-url=/base/', - '--extract-css', '--aot')) - .then(() => expectFileToMatch('dist/test-project/styles.css', - '/base/assets/global-img-absolute.svg')) - .then(() => expectFileToMatch('dist/test-project/styles.css', - /global-img-relative\.png/)) - .then(() => expectFileToMatch('dist/test-project/main.js', - '/base/assets/component-img-absolute.svg')) - .then(() => expectFileToMatch('dist/test-project/main.js', - /\/base\/component-img-relative\.png/)) - // Check with only base-href flag. - .then(() => ng('build', '--base-href=/base/', - '--extract-css', '--aot')) - .then(() => expectFileToMatch('dist/test-project/styles.css', - '/base/assets/global-img-absolute.svg')) - .then(() => expectFileToMatch('dist/test-project/styles.css', - /global-img-relative\.png/)) - .then(() => expectFileToMatch('dist/test-project/main.js', - '/base/assets/component-img-absolute.svg')) - .then(() => expectFileToMatch('dist/test-project/main.js', - /component-img-relative\.png/)); -} diff --git a/tests/legacy-cli/e2e/tests/build/delete-output-path.ts b/tests/legacy-cli/e2e/tests/build/delete-output-path.ts deleted file mode 100644 index 3e8047418aa0..000000000000 --- a/tests/legacy-cli/e2e/tests/build/delete-output-path.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectToFail} from '../../utils/utils'; -import {deleteFile, expectFileToExist} from '../../utils/fs'; -import {getGlobalVariable} from '../../utils/env'; - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - return ng('build') - // This is supposed to fail since there's a missing file - .then(() => deleteFile('src/app/app.component.ts')) - // The build fails but we don't delete the output of the previous build. - .then(() => expectToFail(() => ng('build', '--delete-output-path=false'))) - .then(() => expectFileToExist('dist')) - // By default, output path is always cleared. - .then(() => expectToFail(() => ng('build'))) - .then(() => expectToFail(() => expectFileToExist('dist/test-project'))); -} diff --git a/tests/legacy-cli/e2e/tests/build/deploy-url.ts b/tests/legacy-cli/e2e/tests/build/deploy-url.ts deleted file mode 100644 index 929b5006c321..000000000000 --- a/tests/legacy-cli/e2e/tests/build/deploy-url.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ng } from '../../utils/process'; -import { copyProjectAsset } from '../../utils/assets'; -import { expectFileToMatch, writeMultipleFiles } from '../../utils/fs'; -import { updateJsonFile } from '../../utils/project'; -import { getGlobalVariable } from '../../utils/env'; - - -export default function () { - return Promise.resolve() - .then(() => writeMultipleFiles({ - 'src/styles.css': 'div { background: url("./assets/more.png"); }', - })) - // use image with file size >10KB to prevent inlining - .then(() => copyProjectAsset('images/spectrum.png', './src/assets/more.png')) - .then(() => ng('build', '--deploy-url=deployUrl/', '--extract-css')) - .then(() => expectFileToMatch('dist/test-project/index.html', 'deployUrl/main.js')) - // verify --deploy-url isn't applied to extracted css urls - .then(() => expectFileToMatch('dist/test-project/styles.css', - /url\(['"]?more\.png['"]?\)/)) - .then(() => ng('build', '--deploy-url=http://example.com/some/path/', '--extract-css')) - .then(() => expectFileToMatch('dist/test-project/index.html', 'http://example.com/some/path/main.js')) - // verify --deploy-url is applied to non-extracted css urls - .then(() => ng('build', '--deploy-url=deployUrl/', '--extract-css=false')) - .then(() => expectFileToMatch('dist/test-project/styles.js', - /\(['"]?deployUrl\/more\.png['"]?\)/)) - .then(() => expectFileToMatch('dist/test-project/runtime.js', - /__webpack_require__\.p = "deployUrl\/";/)); - // // verify slash is appended to the end of --deploy-url if missing - // .then(() => ng('build', '--deploy-url=deployUrl', '--extract-css=false')) - // // skip this in ejected tests - // .then(() => getGlobalVariable('argv').eject - // ? Promise.resolve() - // : expectFileToMatch('dist/test-project/untime.js', /__webpack_require__\.p = "deployUrl\/";/)); -} diff --git a/tests/legacy-cli/e2e/tests/build/dynamic-import.ts b/tests/legacy-cli/e2e/tests/build/dynamic-import.ts deleted file mode 100644 index 296cab2ef2b4..000000000000 --- a/tests/legacy-cli/e2e/tests/build/dynamic-import.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as fs from 'fs'; -import { writeFile } from '../../utils/fs'; -import { ng } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; - - -export default async function() { - // Add a lazy module - await ng('generate', 'module', 'lazy'); - await updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.lazyModules = [ - 'src/app/lazy/lazy.module', - ]; - }); - - // Update the app component to use the lazy module - await writeFile('src/app/app.component.ts', ` - import { Component, SystemJsNgModuleLoader } from '@angular/core'; - - @Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.css'], - }) - export class AppComponent { - title = 'app'; - constructor(loader: SystemJsNgModuleLoader) { - // Module will be split at build time and loaded when requested below - loader.load('app/lazy/lazy.module#LazyModule') - .then((factory) => { /* Use factory here */ }); - } - } - `); - - // Build and look for the split lazy module - await ng('build'); - for (const file of fs.readdirSync('./dist/test-project')) { - if (file === 'src-app-lazy-lazy-module.js') { - // Lazy module chunk was found and succesfully split - return; - } - } - - throw new Error('Lazy module chunk not created'); -} diff --git a/tests/legacy-cli/e2e/tests/build/environment.ts b/tests/legacy-cli/e2e/tests/build/environment.ts deleted file mode 100644 index f00b73680b1f..000000000000 --- a/tests/legacy-cli/e2e/tests/build/environment.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectFileToMatch} from '../../utils/fs'; -import { updateJsonFile } from '../../utils/project'; - - -export default function() { - // Try a prod build. - return Promise.resolve() - .then(() => updateJsonFile('angular.json', configJson => { - const appArchitect = configJson.projects['test-project'].architect; - appArchitect.build.configurations['prod-env'] = { - fileReplacements: [ - { - src: 'src/environments/environment.ts', - replaceWith: 'src/environments/environment.prod.ts', - } - ], - }; - })) - .then(() => ng('build', '--configuration=prod-env')) - .then(() => expectFileToMatch('dist/test-project/main.js', 'production: true')); -} diff --git a/tests/legacy-cli/e2e/tests/build/eval-sourcemap.ts b/tests/legacy-cli/e2e/tests/build/eval-sourcemap.ts deleted file mode 100644 index 2c62f4b25fde..000000000000 --- a/tests/legacy-cli/e2e/tests/build/eval-sourcemap.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {execAndWaitForOutputToMatch, killAllProcesses} from '../../utils/process'; -import {getGlobalVariable} from '../../utils/env'; - - -export default function() { - // TODO(architect): Dev-server does not yet do this. Fix, reenable, validate, then delete this test. - return; - - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - return Promise.resolve() - // Check that ng serve has eval sourcemaps by default. - .then(() => execAndWaitForOutputToMatch('ng', ['serve'], /: Compiled successfully/)) - .then((output) => { - const stdout = output.stdout; - if (/\.js\.map/.test(stdout)) { - throw new Error('Expected eval sourcemap but file sourcemap was present instead.'); - } - }) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }); -} diff --git a/tests/legacy-cli/e2e/tests/build/extract-licenses.ts b/tests/legacy-cli/e2e/tests/build/extract-licenses.ts deleted file mode 100644 index 73ec571dc25b..000000000000 --- a/tests/legacy-cli/e2e/tests/build/extract-licenses.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {join} from 'path'; -import {expectFileToExist} from '../../utils/fs'; -import {expectToFail} from '../../utils/utils'; -import {ng} from '../../utils/process'; - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return ng('build', '--prod', '--extract-licenses=false') - .then(() => expectFileToExist(join(process.cwd(), 'dist'))) - .then(() => expectToFail(() => expectFileToExist('dist/test-project/3rdpartylicenses.txt'))); -} diff --git a/tests/legacy-cli/e2e/tests/build/json.ts b/tests/legacy-cli/e2e/tests/build/json.ts deleted file mode 100644 index f67f50fd2bfd..000000000000 --- a/tests/legacy-cli/e2e/tests/build/json.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; -import {expectGitToBeClean} from '../../utils/git'; -import {getGlobalVariable} from '../../utils/env'; - - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - return ng('build', '--stats-json') - .then(() => expectFileToExist('./dist/test-project/stats.json')) - .then(() => expectGitToBeClean()); -} diff --git a/tests/legacy-cli/e2e/tests/build/module-id.ts b/tests/legacy-cli/e2e/tests/build/module-id.ts deleted file mode 100644 index ad75d230a419..000000000000 --- a/tests/legacy-cli/e2e/tests/build/module-id.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ng } from '../../utils/process'; -import { replaceInFile } from '../../utils/fs'; - - -export default function() { - return Promise.resolve() - .then(() => replaceInFile('src/app/app.component.ts', - '@Component({', - '@Component({ moduleId: module.id,')) - .then(() => ng('build')); -} diff --git a/tests/legacy-cli/e2e/tests/build/no-angular-router.ts b/tests/legacy-cli/e2e/tests/build/no-angular-router.ts deleted file mode 100644 index 5bf898d8824e..000000000000 --- a/tests/legacy-cli/e2e/tests/build/no-angular-router.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectFileToExist, moveFile} from '../../utils/fs'; -import {getGlobalVariable} from '../../utils/env'; -import * as path from 'path'; - - -export default function() { - const tmp = getGlobalVariable('tmp-root'); - - return Promise.resolve() - .then(() => moveFile('node_modules/@angular/router', path.join(tmp, '@angular-router.backup'))) - .then(() => ng('build')) - .then(() => expectFileToExist('./dist/test-project/index.html')) - .then(() => moveFile(path.join(tmp, '@angular-router.backup'), 'node_modules/@angular/router')); -} diff --git a/tests/legacy-cli/e2e/tests/build/no-entry-module.ts b/tests/legacy-cli/e2e/tests/build/no-entry-module.ts deleted file mode 100644 index 67f4857053ea..000000000000 --- a/tests/legacy-cli/e2e/tests/build/no-entry-module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { readFile, writeFile } from '../../utils/fs'; -import { ng } from '../../utils/process'; - - -export default async function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - const mainTs = await readFile('src/main.ts'); - - const newMainTs = mainTs - .replace(/platformBrowserDynamic.*?bootstrapModule.*?;/, '') - + 'console.log(AppModule);'; // Use AppModule to make sure it's imported properly. - - await writeFile('src/main.ts', newMainTs); - await ng('build'); -} diff --git a/tests/legacy-cli/e2e/tests/build/no-implicit-any.ts b/tests/legacy-cli/e2e/tests/build/no-implicit-any.ts deleted file mode 100644 index 08927a41467e..000000000000 --- a/tests/legacy-cli/e2e/tests/build/no-implicit-any.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {updateTsConfig} from '../../utils/project'; -import {ng} from '../../utils/process'; - - -export default function() { - return updateTsConfig(json => { - json['compilerOptions']['noImplicitAny'] = true; - }) - .then(() => ng('build')); -} diff --git a/tests/legacy-cli/e2e/tests/build/output-dir.ts b/tests/legacy-cli/e2e/tests/build/output-dir.ts deleted file mode 100644 index 5b245bb3d983..000000000000 --- a/tests/legacy-cli/e2e/tests/build/output-dir.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {getGlobalVariable} from '../../utils/env'; -import {expectFileToExist} from '../../utils/fs'; -import {expectGitToBeClean} from '../../utils/git'; -import {ng} from '../../utils/process'; -import {updateJsonFile} from '../../utils/project'; -import {expectToFail} from '../../utils/utils'; - - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - return ng('build', '--output-path', 'build-output') - .then(() => expectFileToExist('./build-output/index.html')) - .then(() => expectFileToExist('./build-output/main.js')) - .then(() => expectToFail(expectGitToBeClean)) - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.outputPath = 'config-build-output'; - })) - .then(() => ng('build')) - .then(() => expectFileToExist('./config-build-output/index.html')) - .then(() => expectFileToExist('./config-build-output/main.js')) - .then(() => expectToFail(expectGitToBeClean)); -} diff --git a/tests/legacy-cli/e2e/tests/build/output-hashing.ts b/tests/legacy-cli/e2e/tests/build/output-hashing.ts deleted file mode 100644 index debcfc481bf7..000000000000 --- a/tests/legacy-cli/e2e/tests/build/output-hashing.ts +++ /dev/null @@ -1,41 +0,0 @@ -import {ng} from '../../utils/process'; -import { copyProjectAsset } from '../../utils/assets'; -import { writeMultipleFiles, expectFileToMatch, expectFileMatchToExist } from '../../utils/fs'; - - -function verifyMedia(fileNameRe: RegExp, content: RegExp) { - return expectFileMatchToExist('dist/test-project/', fileNameRe) - .then(fileName => expectFileToMatch(`dist/test-project/${fileName}`, content)); -} - -export default function() { - return Promise.resolve() - .then(() => writeMultipleFiles({ - 'src/styles.css': 'body { background-image: url("./assets/image.png"); }' - })) - // use image with file size >10KB to prevent inlining - .then(() => copyProjectAsset('images/spectrum.png', './src/assets/image.png')) - .then(() => ng('build', '--output-hashing=all')) - .then(() => expectFileToMatch('dist/test-project/index.html', /runtime\.[0-9a-f]{20}\.js/)) - .then(() => expectFileToMatch('dist/test-project/index.html', /main\.[0-9a-f]{20}\.js/)) - .then(() => expectFileToMatch('dist/test-project/index.html', /styles\.[0-9a-f]{20}\.(css|js)/)) - .then(() => verifyMedia(/styles\.[0-9a-f]{20}\.(css|js)/, /image\.[0-9a-f]{20}\.png/)) - - .then(() => ng('build', '--output-hashing=none')) - .then(() => expectFileToMatch('dist/test-project/index.html', /runtime\.js/)) - .then(() => expectFileToMatch('dist/test-project/index.html', /main\.js/)) - .then(() => expectFileToMatch('dist/test-project/index.html', /styles\.(css|js)/)) - .then(() => verifyMedia(/styles\.(css|js)/, /image\.png/)) - - .then(() => ng('build', '--output-hashing=media')) - .then(() => expectFileToMatch('dist/test-project/index.html', /runtime\.js/)) - .then(() => expectFileToMatch('dist/test-project/index.html', /main\.js/)) - .then(() => expectFileToMatch('dist/test-project/index.html', /styles\.(css|js)/)) - .then(() => verifyMedia(/styles\.(css|js)/, /image\.[0-9a-f]{20}\.png/)) - - .then(() => ng('build', '--output-hashing=bundles')) - .then(() => expectFileToMatch('dist/test-project/index.html', /runtime\.[0-9a-f]{20}\.js/)) - .then(() => expectFileToMatch('dist/test-project/index.html', /main\.[0-9a-f]{20}\.js/)) - .then(() => expectFileToMatch('dist/test-project/index.html', /styles\.[0-9a-f]{20}\.(css|js)/)) - .then(() => verifyMedia(/styles\.[0-9a-f]{20}\.(css|js)/, /image\.png/)); -} diff --git a/tests/legacy-cli/e2e/tests/build/platform-server.ts b/tests/legacy-cli/e2e/tests/build/platform-server.ts deleted file mode 100644 index 1aeab4b099a6..000000000000 --- a/tests/legacy-cli/e2e/tests/build/platform-server.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { normalize } from 'path'; - -import { updateJsonFile, updateTsConfig } from '../../utils/project'; -import { - expectFileToMatch, - writeFile, - replaceInFile, - prependToFile, - appendToFile, -} from '../../utils/fs'; -import { ng, silentNpm, exec } from '../../utils/process'; -import { getGlobalVariable } from '../../utils/env'; -import { readNgVersion } from '../../utils/version'; -import { expectToFail } from '../../utils/utils'; - -export default function () { - // Skip this for ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - let platformServerVersion = readNgVersion(); - let httpVersion = readNgVersion(); - - if (getGlobalVariable('argv')['ng-snapshots']) { - platformServerVersion = 'github:angular/platform-server-builds'; - httpVersion = 'github:angular/http-builds'; - } - - // Skip this test in Angular 2/4. - if (getGlobalVariable('argv').ng2 || getGlobalVariable('argv').ng4) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => updateJsonFile('package.json', packageJson => { - const dependencies = packageJson['dependencies']; - dependencies['@angular/platform-server'] = platformServerVersion; - // ServerModule depends on @angular/http regardless the app's dependency. - dependencies['@angular/http'] = httpVersion; - })) - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect['server'] = { - builder: '@angular-devkit/build-angular:server', - options: { - outputPath: 'dist/test-project-server', - main: 'src/main.server.ts', - tsConfig: 'src/tsconfig.server.json', - }, - }; - })) - .then(() => writeFile('./src/tsconfig.server.json', ` - { - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../dist-server", - "baseUrl": "./", - "module": "commonjs", - "types": [] - }, - "exclude": [ - "test.ts", - "**/*.spec.ts" - ], - "angularCompilerOptions": { - "entryModule": "app/app.server.module#AppServerModule" - } - } - `)) - .then(() => writeFile('./src/main.server.ts', ` - import { enableProdMode } from '@angular/core'; - - import { environment } from './environments/environment'; - - if (environment.production) { - enableProdMode(); - } - - export { AppServerModule } from './app/app.server.module'; - `)) - .then(() => writeFile('./src/app/app.server.module.ts', ` - import { NgModule } from '@angular/core'; - import { BrowserModule } from '@angular/platform-browser'; - import { ServerModule } from '@angular/platform-server'; - - import { AppModule } from './app.module'; - import { AppComponent } from './app.component'; - - @NgModule({ - imports: [ - AppModule, - BrowserModule.withServerTransition(\{ appId: 'app' \}), - ServerModule, - ], - bootstrap: [AppComponent], - }) - export class AppServerModule {} - `)) - .then(() => silentNpm('install')) - // This part of the test requires a non-aot build, which isn't available anymore. - // .then(() => ng('run', 'test-project:server')) - // // files were created successfully - // .then(() => expectFileToMatch('dist/test-project-server/main.js', - // /exports.*AppServerModule/)) - // .then(() => writeFile('./index.js', ` - // require('zone.js/dist/zone-node'); - // require('reflect-metadata'); - // const fs = require('fs'); - // const \{ AppServerModule \} = require('./dist/test-project-server/main'); - // const \{ renderModule \} = require('@angular/platform-server'); - - // renderModule(AppServerModule, \{ - // url: '/', - // document: '' - // \}).then(html => \{ - // fs.writeFileSync('dist/test-project-server/index.html', html); - // \}); - // `)) - // .then(() => exec(normalize('node'), 'index.js')) - // .then(() => expectFileToMatch('dist/test-project-server/index.html', - // new RegExp('

Here are some links to help you start:

'))) - .then(() => ng('run', 'test-project:server')) - // files were created successfully - .then(() => expectFileToMatch('dist/test-project-server/main.js', - /exports.*AppServerModuleNgFactory/)) - .then(() => writeFile('./index.js', ` - require('zone.js/dist/zone-node'); - require('reflect-metadata'); - const fs = require('fs'); - const \{ AppServerModuleNgFactory \} = require('./dist/test-project-server/main'); - const \{ renderModuleFactory \} = require('@angular/platform-server'); - - renderModuleFactory(AppServerModuleNgFactory, \{ - url: '/', - document: '' - \}).then(html => \{ - fs.writeFileSync('dist/test-project-server/index.html', html); - \}); - `)) - .then(() => exec(normalize('node'), 'index.js')) - .then(() => expectFileToMatch('dist/test-project-server/index.html', - /Here are some links to help you start: <\/h2>/)) - .then(() => expectFileToMatch('./dist/test-project-server/main.js', - /require\(["']@angular\/[^"']*["']\)/)) - - // Check externals. - .then(() => prependToFile('./src/app/app.server.module.ts', ` - import 'zone.js/dist/zone-node'; - import 'reflect-metadata'; - `) - .then(() => appendToFile('./src/app/app.server.module.ts', ` - import * as fs from 'fs'; - import { renderModule } from '@angular/platform-server'; - - renderModule(AppModule, \{ - url: '/', - document: '' - \}).then(html => \{ - fs.writeFileSync('dist/test-project-server/index.html', html); - \}); - `))) - // This part of the test requires a non-aot build, which isn't available anymore. - // .then(() => ng('run', 'test-project:server', '--bundle-dependencies=all')) - // .then(() => expectToFail(() => expectFileToMatch('./dist/test-project-server/main.js', - // /require\(["']@angular\/[^"']*["']\)/))) - // .then(() => exec(normalize('node'), 'dist/test-project-server/main.js')); -} diff --git a/tests/legacy-cli/e2e/tests/build/poll.ts b/tests/legacy-cli/e2e/tests/build/poll.ts deleted file mode 100644 index e2aacf46f586..000000000000 --- a/tests/legacy-cli/e2e/tests/build/poll.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { appendToFile } from '../../utils/fs'; -import { - killAllProcesses, - waitForAnyProcessOutputToMatch, -} from '../../utils/process'; -import { ngServe } from '../../utils/project'; -import { expectToFail, wait } from '../../utils/utils'; - -const webpackGoodRegEx = /: Compiled successfully./; - -export default async function() { - try { - await ngServe('--poll=10000'); - - // Wait before editing a file. - // Editing too soon seems to trigger a rebuild and throw polling out of whack. - await wait(3000); - await appendToFile('src/main.ts', 'console.log(1);'); - - // We have to wait poll time + rebuild build time for the regex match. - await waitForAnyProcessOutputToMatch(webpackGoodRegEx, 14000); - - // No rebuilds should occur for a while - await appendToFile('src/main.ts', 'console.log(1);'); - await expectToFail(() => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 7000)); - - // But a rebuild should happen roughly within the 10 second window. - await waitForAnyProcessOutputToMatch(webpackGoodRegEx, 7000); - } finally { - killAllProcesses(); - } -} diff --git a/tests/legacy-cli/e2e/tests/build/polyfills.ts b/tests/legacy-cli/e2e/tests/build/polyfills.ts deleted file mode 100644 index cc0e147afd4e..000000000000 --- a/tests/legacy-cli/e2e/tests/build/polyfills.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { oneLineTrim } from 'common-tags'; -import { - expectFileSizeToBeUnder, - expectFileToExist, - expectFileToMatch, - getFileSize, -} from '../../utils/fs'; -import { ng } from '../../utils/process'; -import { expectToFail } from '../../utils/utils'; - -export default async function () { - await ng('build'); - // files were created successfully - await expectFileToMatch('dist/test-project/polyfills.js', 'core-js/es7/reflect'); - await expectFileToMatch('dist/test-project/polyfills.js', 'zone.js'); - expectFileToMatch('dist/test-project/index.html', oneLineTrim` - - - `); - const jitPolyfillSize = await getFileSize('dist/test-project/polyfills.js'); - - await ng('build', '--aot'); - // files were created successfully - await expectFileToExist('dist/test-project/polyfills.js'); - await expectFileSizeToBeUnder('dist/test-project/polyfills.js', jitPolyfillSize); - await expectToFail(() => expectFileToMatch('dist/test-project/polyfills.js', 'core-js/es7/reflect')); - await expectFileToMatch('dist/test-project/polyfills.js', 'zone.js'); - expectFileToMatch('dist/test-project/index.html', oneLineTrim` - - - `); -} diff --git a/tests/legacy-cli/e2e/tests/build/prod-build.ts b/tests/legacy-cli/e2e/tests/build/prod-build.ts deleted file mode 100644 index 674f77161f3a..000000000000 --- a/tests/legacy-cli/e2e/tests/build/prod-build.ts +++ /dev/null @@ -1,30 +0,0 @@ -import {join} from 'path'; -import {readdirSync} from 'fs'; -import {expectFileToExist, expectFileToMatch} from '../../utils/fs'; -import {ng} from '../../utils/process'; -import {expectGitToBeClean} from '../../utils/git'; -import {getGlobalVariable} from '../../utils/env'; - - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - // Skip this in ejected tests. - const ejected = getGlobalVariable('argv').eject; - - // Can't use the `ng` helper because somewhere the environment gets - // stuck to the first build done - return ng('build', '--prod') - .then(() => expectFileToExist(join(process.cwd(), 'dist'))) - // Check for cache busting hash script src - .then(() => expectFileToMatch('dist/test-project/index.html', /main\.[0-9a-f]{20}\.js/)) - .then(() => expectFileToMatch('dist/test-project/index.html', /styles\.[0-9a-f]{20}\.css/)) - .then(() => expectFileToMatch('dist/test-project/3rdpartylicenses.txt', /MIT/)) - // Defaults to AoT - .then(() => { - const main = readdirSync('./dist/test-project').find(name => !!name.match(/main.[a-z0-9]+\.js/)); - expectFileToMatch(`dist/test-project/${main}`, /bootstrapModuleFactory\(/); - }) - // Check that the process didn't change local files. - .then(() => !ejected && expectGitToBeClean()); -} diff --git a/tests/legacy-cli/e2e/tests/build/rebuild-css-change.ts b/tests/legacy-cli/e2e/tests/build/rebuild-css-change.ts deleted file mode 100644 index 5104466a77b0..000000000000 --- a/tests/legacy-cli/e2e/tests/build/rebuild-css-change.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - killAllProcesses, - waitForAnyProcessOutputToMatch, - execAndWaitForOutputToMatch -} from '../../utils/process'; -import {appendToFile} from '../../utils/fs'; -import {getGlobalVariable} from '../../utils/env'; - -const webpackGoodRegEx = /: Compiled successfully./; - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - if (process.platform.startsWith('win')) { - return Promise.resolve(); - } - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - - return execAndWaitForOutputToMatch('ng', ['serve'], webpackGoodRegEx) - // Should trigger a rebuild. - .then(() => appendToFile('src/app/app.component.css', ':host { color: blue; }')) - .then(() => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 10000)) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }); -} diff --git a/tests/legacy-cli/e2e/tests/build/rebuild-deps-type-check.ts b/tests/legacy-cli/e2e/tests/build/rebuild-deps-type-check.ts deleted file mode 100644 index db433abfb735..000000000000 --- a/tests/legacy-cli/e2e/tests/build/rebuild-deps-type-check.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { - killAllProcesses, - waitForAnyProcessOutputToMatch, - execAndWaitForOutputToMatch, -} from '../../utils/process'; -import {writeFile, prependToFile, appendToFile} from '../../utils/fs'; -import {getGlobalVariable} from '../../utils/env'; - - -const doneRe = - /: Compiled successfully.|: Failed to compile./; -const errorRe = /ERROR in/; - - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - if (process.platform.startsWith('win')) { - return Promise.resolve(); - } - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - return Promise.resolve() - // Create and import files. - .then(() => writeFile('src/funky2.ts', ` - export function funky2(value: string): string { - return value + 'hello'; - } - `)) - .then(() => writeFile('src/funky.ts', ` - export * from './funky2'; - `)) - .then(() => prependToFile('src/main.ts', ` - import { funky2 } from './funky'; - `)) - .then(() => appendToFile('src/main.ts', ` - console.log(funky2('town')); - `)) - // Should trigger a rebuild, no error expected. - .then(() => execAndWaitForOutputToMatch('ng', ['serve'], doneRe)) - // Make an invalid version of the file. - // Should trigger a rebuild, this time an error is expected. - .then(() => Promise.all([ - waitForAnyProcessOutputToMatch(errorRe, 20000), - writeFile('src/funky2.ts', ` - export function funky2(value: number): number { - return value + 1; - } - `) - ])) - .then((results) => { - const stderr = results[0].stderr; - if (!/ERROR in (.*src\/)?main\.ts/.test(stderr)) { - throw new Error('Expected an error but none happened.'); - } - }) - // Change an UNRELATED file and the error should still happen. - // Should trigger a rebuild, this time an error is also expected. - .then(() => Promise.all([ - waitForAnyProcessOutputToMatch(errorRe, 20000), - appendToFile('src/app/app.module.ts', ` - function anything(): number { return 1; } - `) - ])) - .then((results) => { - const stderr = results[0].stderr; - if (!/ERROR in (.*src\/)?main\.ts/.test(stderr)) { - throw new Error('Expected an error to still be there but none was.'); - } - }) - // Fix the error! - .then(() => Promise.all([ - waitForAnyProcessOutputToMatch(doneRe, 20000), - writeFile('src/funky2.ts', ` - export function funky2(value: string): string { - return value + 'hello'; - } - `) - ])) - .then((results) => { - const stderr = results[0].stderr; - if (/ERROR in (.*src\/)?main\.ts/.test(stderr)) { - throw new Error('Expected no error but an error was shown.'); - } - }) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }); -} diff --git a/tests/legacy-cli/e2e/tests/build/rebuild-error.ts b/tests/legacy-cli/e2e/tests/build/rebuild-error.ts deleted file mode 100644 index f282d25d7434..000000000000 --- a/tests/legacy-cli/e2e/tests/build/rebuild-error.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - killAllProcesses, - waitForAnyProcessOutputToMatch, - execAndWaitForOutputToMatch, -} from '../../utils/process'; -import { replaceInFile, readFile, writeFile } from '../../utils/fs'; -import { getGlobalVariable } from '../../utils/env'; -import { wait, expectToFail } from '../../utils/utils'; - - -const failedRe = /: Failed to compile/; -const successRe = /: Compiled successfully/; -const errorRe = /ERROR in/; -const extraErrors = [ - `Final loader didn't return a Buffer or String`, - `doesn't contain a valid alias configuration`, - `main.ts is not part of the TypeScript compilation.`, -]; - -export default function () { - // TODO(architect): This test is behaving oddly both here and in devkit/build-angular. - // It seems to be because of file watchers. - return; - - if (process.platform.startsWith('win')) { - return Promise.resolve(); - } - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - // Skip this test in Angular 2/4. - if (getGlobalVariable('argv').ng2 || getGlobalVariable('argv').ng4) { - return Promise.resolve(); - } - - let origContent: string; - - return Promise.resolve() - // Save the original contents of `./src/app/app.component.ts`. - .then(() => readFile('./src/app/app.component.ts')) - .then((contents) => origContent = contents) - // Add a major static analysis error on a non-main file to the initial build. - .then(() => replaceInFile('./src/app/app.component.ts', `'app-root'`, `(() => 'app-root')()`)) - // Should have an error. - .then(() => execAndWaitForOutputToMatch('ng', ['build', '--watch', '--aot'], failedRe)) - .then((results) => { - const stderr = results.stderr; - if (!stderr.includes('Function calls are not supported') - && !stderr.includes('Function expressions are not supported in decorators')) { - throw new Error(`Expected static analysis error, got this instead:\n${stderr}`); - } - if (extraErrors.some((e) => stderr.includes(e))) { - throw new Error(`Did not expect extra errors but got:\n${stderr}`); - } - }) - // Fix the error, should trigger a successful rebuild. - .then(() => Promise.all([ - waitForAnyProcessOutputToMatch(successRe, 20000), - writeFile('src/app/app.component.ts', origContent) - ])) - .then(() => wait(2000)) - // Add an syntax error to a non-main file. - // Build should still be successfull and error reported on forked type checker. - .then(() => Promise.all([ - waitForAnyProcessOutputToMatch(errorRe, 20000), - writeFile('src/app/app.component.ts', origContent + '\n]]]]]') - ])) - .then((results) => { - const stderr = results[0].stderr; - if (!stderr.includes('Declaration or statement expected.')) { - throw new Error(`Expected syntax error, got this instead:\n${stderr}`); - } - if (extraErrors.some((e) => stderr.includes(e))) { - throw new Error(`Did not expect extra errors but got:\n${stderr}`); - } - }) - // Fix the error, should trigger a successful rebuild. - // We have to wait for the type checker to run, so we expect to NOT - // have an error message in 5s. - .then(() => Promise.all([ - expectToFail(() => waitForAnyProcessOutputToMatch(errorRe, 5000)), - writeFile('src/app/app.component.ts', origContent) - ])) - .then(() => wait(2000)) - // Add a major static analysis error on a rebuild. - // Should fail the rebuild. - .then(() => Promise.all([ - waitForAnyProcessOutputToMatch(failedRe, 20000), - replaceInFile('./src/app/app.component.ts', `'app-root'`, `(() => 'app-root')()`) - ])) - .then((results) => { - const stderr = results[0].stderr; - if (!stderr.includes('Function calls are not supported') - && !stderr.includes('Function expressions are not supported in decorators')) { - throw new Error(`Expected static analysis error, got this instead:\n${stderr}`); - } - if (extraErrors.some((e) => stderr.includes(e))) { - throw new Error(`Did not expect extra errors but got:\n${stderr}`); - } - }) - // Fix the error, should trigger a successful rebuild. - .then(() => Promise.all([ - waitForAnyProcessOutputToMatch(successRe, 20000), - writeFile('src/app/app.component.ts', origContent) - ])) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }); -} diff --git a/tests/legacy-cli/e2e/tests/build/rebuild-ngfactories.ts b/tests/legacy-cli/e2e/tests/build/rebuild-ngfactories.ts deleted file mode 100644 index 5af00acbc546..000000000000 --- a/tests/legacy-cli/e2e/tests/build/rebuild-ngfactories.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { - killAllProcesses, - waitForAnyProcessOutputToMatch, - execAndWaitForOutputToMatch, -} from '../../utils/process'; -import { appendToFile, writeMultipleFiles, replaceInFile, expectFileToMatch } from '../../utils/fs'; -import { getGlobalVariable } from '../../utils/env'; - -const validBundleRegEx = /: Compiled successfully./; - -export default function () { - // TODO(architect): This test is behaving oddly both here and in devkit/build-angular. - // It seems to be because of file watchers. - return; - - if (process.platform.startsWith('win')) { - return Promise.resolve(); - } - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - // Skip this test in Angular 2/4. - if (getGlobalVariable('argv').ng2 || getGlobalVariable('argv').ng4) { - return Promise.resolve(); - } - - return execAndWaitForOutputToMatch('ng', ['build', '--watch', '--aot'], validBundleRegEx) - .then(() => writeMultipleFiles({ - 'src/app/app.component.css': ` - @import './imported-styles.css'; - body {background-color: #00f;} - `, - 'src/app/imported-styles.css': 'p {color: #f00;}', - })) - // Trigger a few rebuilds first. - // The AOT compiler is still optimizing rebuilds on the first rebuild. - .then(() => Promise.all([ - waitForAnyProcessOutputToMatch(validBundleRegEx, 10000), - appendToFile('src/main.ts', 'console.log(1)\n') - ])) - .then(() => Promise.all([ - waitForAnyProcessOutputToMatch(validBundleRegEx, 10000), - appendToFile('src/main.ts', 'console.log(1)\n') - ])) - // Check if html changes are built. - .then(() => Promise.all([ - waitForAnyProcessOutputToMatch(validBundleRegEx, 10000), - appendToFile('src/app/app.component.html', '

HTML_REBUILD_STRING

') - ])) - .then(() => expectFileToMatch('dist/test-project/main.js', 'HTML_REBUILD_STRING')) - // Check if css changes are built. - .then(() => Promise.all([ - waitForAnyProcessOutputToMatch(validBundleRegEx, 10000), - appendToFile('src/app/app.component.css', 'CSS_REBUILD_STRING {color: #f00;}') - ])) - .then(() => expectFileToMatch('dist/test-project/main.js', 'CSS_REBUILD_STRING')) - // Check if css dependency changes are built. - .then(() => Promise.all([ - waitForAnyProcessOutputToMatch(validBundleRegEx, 10000), - appendToFile('src/app/imported-styles.css', 'CSS_DEP_REBUILD_STRING {color: #f00;}') - ])) - .then(() => expectFileToMatch('dist/test-project/main.js', 'CSS_DEP_REBUILD_STRING')) - .then(() => Promise.all([ - waitForAnyProcessOutputToMatch(validBundleRegEx, 10000), - replaceInFile('src/app/app.component.ts', 'app-root', 'app-root-FACTORY_REBUILD_STRING') - ])) - .then(() => expectFileToMatch('dist/test-project/main.js', 'FACTORY_REBUILD_STRING')) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }); -} diff --git a/tests/legacy-cli/e2e/tests/build/rebuild-replacements.ts b/tests/legacy-cli/e2e/tests/build/rebuild-replacements.ts deleted file mode 100644 index 7669da896eaf..000000000000 --- a/tests/legacy-cli/e2e/tests/build/rebuild-replacements.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { getGlobalVariable } from '../../utils/env'; -import { appendToFile } from '../../utils/fs'; -import { - execAndWaitForOutputToMatch, - killAllProcesses, - waitForAnyProcessOutputToMatch, -} from '../../utils/process'; - -const webpackGoodRegEx = /: Compiled successfully./; - -export default async function() { - if (process.platform.startsWith('win')) { - return; - } - - let error; - try { - await execAndWaitForOutputToMatch('ng', ['serve', '--prod'], webpackGoodRegEx); - - // Should trigger a rebuild. - await appendToFile('src/environments/environment.prod.ts', `console.log('PROD');`); - await waitForAnyProcessOutputToMatch(webpackGoodRegEx, 45000); - } catch (e) { - error = e; - } - - killAllProcesses(); - if (error) { - throw error; - } -} diff --git a/tests/legacy-cli/e2e/tests/build/rebuild-types.ts b/tests/legacy-cli/e2e/tests/build/rebuild-types.ts deleted file mode 100644 index 4f9a56e36e8c..000000000000 --- a/tests/legacy-cli/e2e/tests/build/rebuild-types.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - killAllProcesses, - waitForAnyProcessOutputToMatch, - execAndWaitForOutputToMatch, -} from '../../utils/process'; -import { writeFile, prependToFile } from '../../utils/fs'; -import {getGlobalVariable} from '../../utils/env'; - - -const successRe = /: Compiled successfully/; - -export default async function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - if (process.platform.startsWith('win')) { - return; - } - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return; - } - - await writeFile('src/app/type.ts', `export type MyType = number;`); - await prependToFile('src/app/app.component.ts', 'import { MyType } from "./type";\n'); - - try { - await execAndWaitForOutputToMatch('ng', ['serve'], successRe); - - await Promise.all([ - waitForAnyProcessOutputToMatch(successRe, 20000), - writeFile('src/app/type.ts', `export type MyType = string;`), - ]); - } finally { - killAllProcesses(); - } -} diff --git a/tests/legacy-cli/e2e/tests/build/script-target.ts b/tests/legacy-cli/e2e/tests/build/script-target.ts deleted file mode 100644 index 4d09d8b5affe..000000000000 --- a/tests/legacy-cli/e2e/tests/build/script-target.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expectFileToMatch } from '../../utils/fs'; -import { ng } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; -import { getGlobalVariable } from '../../utils/env'; - - -export default function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - // Skip this test in Angular 2, it had different bundles. - if (getGlobalVariable('argv').ng2) { - return Promise.resolve(); - } - - return Promise.resolve() - .then(() => updateJsonFile('tsconfig.json', configJson => { - const compilerOptions = configJson['compilerOptions']; - compilerOptions['target'] = 'es2015'; - })) - .then(() => ng('build', '--optimization', '--output-hashing=none', '--vendor-chunk')) - // Check class constructors are present in the vendor output. - .then(() => expectFileToMatch('dist/test-project/vendor.js', /class \w{constructor\(\){/)); -} diff --git a/tests/legacy-cli/e2e/tests/build/service-worker.ts b/tests/legacy-cli/e2e/tests/build/service-worker.ts deleted file mode 100644 index ff2d165256ba..000000000000 --- a/tests/legacy-cli/e2e/tests/build/service-worker.ts +++ /dev/null @@ -1,57 +0,0 @@ -import {join} from 'path'; -import {getGlobalVariable} from '../../utils/env'; -import {expectFileNotToExist, expectFileToExist, expectFileToMatch, writeFile} from '../../utils/fs'; -import {ng, npm, silentNpm} from '../../utils/process'; - -const MANIFEST = { - index: '/index.html', - assetGroups: [{ - name: 'cli', - resources: { - files: [ - '/**/*.html', - '/**/*.js', - '/**/*.css', - '/assets/**/*', - '!/ngsw-worker.js', - ], - urls: [ - 'http://test.com/foo/bar', - ], - }, - }], -}; - -export default function() { - // Skip this in ejected tests. - if (getGlobalVariable('argv').eject) { - return Promise.resolve(); - } - - // Can't use the `ng` helper because somewhere the environment gets - // stuck to the first build done - return silentNpm('remove', '@angular/service-worker') - .then(() => silentNpm('install', '@angular/service-worker')) - .then(() => ng('config', 'projects.test-project.architect.build.options.serviceWorker', 'true')) - .then(() => writeFile('src/ngsw-config.json', JSON.stringify(MANIFEST, null, 2))) - .then(() => ng('build', '--optimization')) - .then(() => expectFileToExist(join(process.cwd(), 'dist'))) - .then(() => expectFileToExist(join(process.cwd(), 'dist/test-project/ngsw.json'))) - .then(() => ng('build', '--optimization', '--base-href=/foo/bar')) - .then(() => expectFileToExist(join(process.cwd(), 'dist/test-project/ngsw.json'))) - .then(() => expectFileToMatch('dist/test-project/ngsw.json', /"\/foo\/bar\/index.html"/)) - .then(() => ng('build', '--optimization', '--service-worker=false')) - .then(() => expectFileNotToExist('dist/test-project/ngsw.json')) - .then(() => writeFile('node_modules/@angular/service-worker/safety-worker.js', 'false')) - .then(() => ng('build', '--optimization')) - .then(() => expectFileToExist('dist/test-project/safety-worker.js')) - .then(() => expectFileToExist('dist/test-project/worker-basic.min.js')); - // WEBPACK4_DISABLED - eject temporarily disabled for webpack 4 integration - // .then(() => ng('eject', '--prod')) - // .then(() => silentNpm('install')) - // .then(() => npm('run', 'build')) - // .then(() => expectFileToMatch('package.json', /"sw-config"/)) - // .then(() => expectFileToExist(join(process.cwd(), 'dist/ngsw-worker.js'))) - // .then(() => expectFileToExist(join(process.cwd(), 'dist/ngsw.json'))) - // .then(() => ng('set', 'apps.0.serviceWorker=false')); -} diff --git a/tests/legacy-cli/e2e/tests/build/sourcemap.ts b/tests/legacy-cli/e2e/tests/build/sourcemap.ts deleted file mode 100644 index ee47abf5986c..000000000000 --- a/tests/legacy-cli/e2e/tests/build/sourcemap.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; -import {expectToFail} from '../../utils/utils'; - - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return ng('build', '--source-map') - .then(() => expectFileToExist('dist/test-project/main.js.map')) - - .then(() => ng('build', '--source-map', 'false')) - .then(() => expectToFail(() => expectFileToExist('dist/test-project/main.js.map'))) - - .then(() => ng('build', '--optimization', '--output-hashing=none')) - .then(() => expectToFail(() => expectFileToExist('dist/test-project/main..js.map'))) - - .then(() => ng('build', '--optimization', '--output-hashing=none', '--source-map')) - .then(() => expectFileToExist('dist/test-project/main.js.map')); -} diff --git a/tests/legacy-cli/e2e/tests/build/styles/dart-sass.ts b/tests/legacy-cli/e2e/tests/build/styles/dart-sass.ts deleted file mode 100644 index 9bef49828c79..000000000000 --- a/tests/legacy-cli/e2e/tests/build/styles/dart-sass.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - deleteFile, - expectFileToMatch, - replaceInFile, - writeMultipleFiles, -} from '../../../utils/fs'; -import { ng, silentExec, silentNpm } from '../../../utils/process'; -import { updateJsonFile } from '../../../utils/project'; -import { expectToFail } from '../../../utils/utils'; - - -export default async function () { - if (process.platform.startsWith('win')) { - return; - } - - await writeMultipleFiles({ - 'src/styles.scss': '@import \'./imported-styles.scss\';\nbody { background-color: blue; }', - 'src/imported-styles.scss': 'p { background-color: red; }', - 'src/app/app.component.scss': '.outer { .inner { background: #fff; } }', - }); - await deleteFile('src/app/app.component.css'); - await updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.styles = [ - { input: 'src/styles.scss' }, - ]; - }); - await replaceInFile('src/app/app.component.ts', './app.component.css', './app.component.scss'); - - await silentExec('rm', '-rf', 'node_modules/node-sass'); - await expectToFail(() => ng('build', '--extract-css', '--source-map')); - - await silentNpm('install', 'sass'); - await silentExec('rm', '-rf', 'node_modules/node-sass'); - await ng('build', '--extract-css', '--source-map'); - - await expectFileToMatch('dist/test-project/styles.css', /body\s*{\s*background-color: blue;\s*}/); - await expectFileToMatch('dist/test-project/styles.css', /p\s*{\s*background-color: red;\s*}/); - await expectToFail(() => expectFileToMatch('dist/test-project/styles.css', '"mappings":""')); - await expectFileToMatch('dist/test-project/main.js', /.outer.*.inner.*background:\s*#[fF]+/); - - await silentNpm('install', 'node-gyp'); - await silentNpm('install', 'fibers'); - await silentExec('rm', '-rf', 'node_modules/node-sass'); - await ng('build', '--extract-css', '--source-map'); - - await expectFileToMatch('dist/test-project/styles.css', /body\s*{\s*background-color: blue;\s*}/); - await expectFileToMatch('dist/test-project/styles.css', /p\s*{\s*background-color: red;\s*}/); - await expectToFail(() => expectFileToMatch('dist/test-project/styles.css', '"mappings":""')); - await expectFileToMatch('dist/test-project/main.js', /.outer.*.inner.*background:\s*#[fF]+/); -} diff --git a/tests/legacy-cli/e2e/tests/build/styles/empty-style-urls.ts b/tests/legacy-cli/e2e/tests/build/styles/empty-style-urls.ts deleted file mode 100644 index 6d0db09ab900..000000000000 --- a/tests/legacy-cli/e2e/tests/build/styles/empty-style-urls.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { writeMultipleFiles } from '../../../utils/fs'; -import { ng } from '../../../utils/process'; - - -export default function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return Promise.resolve() - // Write assets. - .then(_ => writeMultipleFiles({ - './src/app/app.component.ts': ` - import { Component } from '@angular/core'; - - @Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: [] - }) - export class AppComponent { - title = 'app'; - } - ` - })) - .then(() => ng('build')); -} diff --git a/tests/legacy-cli/e2e/tests/build/styles/extract-css.ts b/tests/legacy-cli/e2e/tests/build/styles/extract-css.ts deleted file mode 100644 index 35c993cf332c..000000000000 --- a/tests/legacy-cli/e2e/tests/build/styles/extract-css.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - writeMultipleFiles, - expectFileToExist, - expectFileToMatch -} from '../../../utils/fs'; -import { ng } from '../../../utils/process'; -import { updateJsonFile } from '../../../utils/project'; -import { expectToFail } from '../../../utils/utils'; -import { oneLineTrim } from 'common-tags'; - -export default function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return Promise.resolve() - .then(() => writeMultipleFiles({ - 'src/string-style.css': '.string-style { color: red }', - 'src/input-style.css': '.input-style { color: red }', - 'src/lazy-style.css': '.lazy-style { color: red }', - 'src/pre-rename-style.css': '.pre-rename-style { color: red }', - 'src/pre-rename-lazy-style.css': '.pre-rename-lazy-style { color: red }', - })) - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.styles = [ - { input: 'src/string-style.css' }, - { input: 'src/input-style.css' }, - { input: 'src/lazy-style.css', lazy: true }, - { input: 'src/pre-rename-style.css', bundleName: 'renamed-style' }, - { input: 'src/pre-rename-lazy-style.css', bundleName: 'renamed-lazy-style', lazy: true } - ]; - })) - .then(() => ng('build', '--extract-css')) - // files were created successfully - .then(() => expectFileToMatch('dist/test-project/styles.css', '.string-style')) - .then(() => expectFileToMatch('dist/test-project/styles.css', '.input-style')) - .then(() => expectFileToMatch('dist/test-project/lazy-style.css', '.lazy-style')) - .then(() => expectFileToMatch('dist/test-project/renamed-style.css', '.pre-rename-style')) - .then(() => expectFileToMatch('dist/test-project/renamed-lazy-style.css', '.pre-rename-lazy-style')) - // there are no js entry points for css only bundles - .then(() => expectToFail(() => expectFileToExist('dist/test-project/style.js'))) - .then(() => expectToFail(() => expectFileToExist('dist/test-project/lazy-style.js'))) - .then(() => expectToFail(() => expectFileToExist('dist/test-project/renamed-style.js'))) - .then(() => expectToFail(() => expectFileToExist('dist/test-project/renamed-lazy-style.js'))) - // index.html lists the right bundles - .then(() => expectFileToMatch('dist/test-project/index.html', new RegExp(oneLineTrim` - - - `))) - .then(() => expectFileToMatch('dist/test-project/index.html', oneLineTrim` - - - - - `)) - // also check when css isn't extracted - .then(() => ng('build', '--no-extract-css')) - // files were created successfully - .then(() => expectFileToMatch('dist/test-project/styles.js', '.string-style')) - .then(() => expectFileToMatch('dist/test-project/styles.js', '.input-style')) - .then(() => expectFileToMatch('dist/test-project/lazy-style.js', '.lazy-style')) - .then(() => expectFileToMatch('dist/test-project/renamed-style.js', '.pre-rename-style')) - .then(() => expectFileToMatch('dist/test-project/renamed-lazy-style.js', '.pre-rename-lazy-style')) - // index.html lists the right bundles - .then(() => expectFileToMatch('dist/test-project/index.html', oneLineTrim` - - - - - - - `)); -} diff --git a/tests/legacy-cli/e2e/tests/build/styles/imports.ts b/tests/legacy-cli/e2e/tests/build/styles/imports.ts deleted file mode 100644 index c7a5f7cc1e26..000000000000 --- a/tests/legacy-cli/e2e/tests/build/styles/imports.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { - writeMultipleFiles, - expectFileToMatch, - replaceInFile -} from '../../../utils/fs'; -import { expectToFail } from '../../../utils/utils'; -import { ng } from '../../../utils/process'; -import { stripIndents } from 'common-tags'; -import { updateJsonFile } from '../../../utils/project'; -import { getGlobalVariable } from '../../../utils/env'; - -export default function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - // Disable parts of it in webpack tests. - const ejected = getGlobalVariable('argv').eject; - - const extensions = ['css', 'scss', 'less', 'styl']; - let promise = Promise.resolve(); - - extensions.forEach(ext => { - promise = promise.then(() => { - return writeMultipleFiles({ - [`src/styles.${ext}`]: stripIndents` - @import './imported-styles.${ext}'; - body { background-color: #00f; } - `, - [`src/imported-styles.${ext}`]: stripIndents` - p { background-color: #f00; } - `, - [`src/app/app.component.${ext}`]: stripIndents` - @import './imported-component-styles.${ext}'; - .outer { - .inner { - background: #fff; - } - } - `, - [`src/app/imported-component-styles.${ext}`]: stripIndents` - h1 { background: #000; } - `}) - // change files to use preprocessor - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.styles = [ - { input: `src/styles.${ext}` }, - ]; - })) - .then(() => replaceInFile('src/app/app.component.ts', - './app.component.css', `./app.component.${ext}`)) - // run build app - .then(() => ng('build', '--extract-css', '--source-map')) - // verify global styles - .then(() => expectFileToMatch('dist/test-project/styles.css', - /body\s*{\s*background-color: #00f;\s*}/)) - .then(() => expectFileToMatch('dist/test-project/styles.css', - /p\s*{\s*background-color: #f00;\s*}/)) - // verify global styles sourcemap - .then(() => expectToFail(() => - expectFileToMatch('dist/test-project/styles.css', '"mappings":""'))) - // verify component styles - .then(() => expectFileToMatch('dist/test-project/main.js', - /.outer.*.inner.*background:\s*#[fF]+/)) - .then(() => expectFileToMatch('dist/test-project/main.js', - /h1.*background:\s*#000+/)) - // Also check imports work on ng test - .then(() => !ejected && ng('test', '--watch=false')) - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.styles = [ - { input: `src/styles.css` }, - ]; - })) - .then(() => replaceInFile('src/app/app.component.ts', - `./app.component.${ext}`, './app.component.css')); - }); - }); - - return promise; -} diff --git a/tests/legacy-cli/e2e/tests/build/styles/include-paths.ts b/tests/legacy-cli/e2e/tests/build/styles/include-paths.ts deleted file mode 100644 index c685abe6f979..000000000000 --- a/tests/legacy-cli/e2e/tests/build/styles/include-paths.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - writeMultipleFiles, - expectFileToMatch, - replaceInFile, - createDir -} from '../../../utils/fs'; -import { ng } from '../../../utils/process'; -import { updateJsonFile } from '../../../utils/project'; - -export default function () { - // TODO(architect): The compat layer doesn't yet process `includePaths`. - return; - - return Promise.resolve() - .then(() => createDir('src/style-paths')) - .then(() => writeMultipleFiles({ - 'src/style-paths/_variables.scss': '$primary-color: red;', - 'src/styles.scss': ` - @import 'variables'; - h1 { color: $primary-color; } - `, - 'src/app/app.component.scss': ` - @import 'variables'; - h2 { background-color: $primary-color; } - `, - 'src/style-paths/variables.styl': '$primary-color = green', - 'src/styles.styl': ` - @import 'variables' - h3 - color: $primary-color - `, - 'src/app/app.component.styl': ` - @import 'variables' - h4 - background-color: $primary-color - `, - 'src/style-paths/variables.less': '@primary-color: #ADDADD;', - 'src/styles.less': ` - @import 'variables'; - h5 { color: @primary-color; } - `, - 'src/app/app.component.less': ` - @import 'variables'; - h6 { color: @primary-color; } - ` - })) - .then(() => replaceInFile('src/app/app.component.ts', `'./app.component.css\'`, - `'./app.component.scss', './app.component.styl', './app.component.less'`)) - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.styles = [ - { input: 'src/styles.scss' }, - { input: 'src/styles.styl' }, - { input: 'src/styles.less' }, - ]; - appArchitect.build.options.stylePreprocessorOptions = { - includePaths: [ - 'src/style-paths' - ] - }; - })) - // files were created successfully - .then(() => ng('build', '--extract-css')) - .then(() => expectFileToMatch('dist/test-project/styles.css', /h1\s*{\s*color: red;\s*}/)) - .then(() => expectFileToMatch('dist/test-project/main.js', /h2.*{.*color: red;.*}/)) - .then(() => expectFileToMatch('dist/test-project/styles.css', /h3\s*{\s*color: #008000;\s*}/)) - .then(() => expectFileToMatch('dist/test-project/main.js', /h4.*{.*color: #008000;.*}/)) - .then(() => expectFileToMatch('dist/test-project/styles.css', /h5\s*{\s*color: #ADDADD;\s*}/)) - .then(() => expectFileToMatch('dist/test-project/main.js', /h6.*{.*color: #ADDADD;.*}/)) - .then(() => ng('build', '--extract-css', '--aot')) - .then(() => expectFileToMatch('dist/test-project/styles.css', /h1\s*{\s*color: red;\s*}/)) - .then(() => expectFileToMatch('dist/test-project/main.js', /h2.*{.*color: red;.*}/)) - .then(() => expectFileToMatch('dist/test-project/styles.css', /h3\s*{\s*color: #008000;\s*}/)) - .then(() => expectFileToMatch('dist/test-project/main.js', /h4.*{.*color: #008000;.*}/)) - .then(() => expectFileToMatch('dist/test-project/styles.css', /h5\s*{\s*color: #ADDADD;\s*}/)) - .then(() => expectFileToMatch('dist/test-project/main.js', /h6.*{.*color: #ADDADD;.*}/)); -} diff --git a/tests/legacy-cli/e2e/tests/build/styles/less.ts b/tests/legacy-cli/e2e/tests/build/styles/less.ts deleted file mode 100644 index a8616a78484b..000000000000 --- a/tests/legacy-cli/e2e/tests/build/styles/less.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { - writeMultipleFiles, - deleteFile, - expectFileToMatch, - replaceInFile -} from '../../../utils/fs'; -import { expectToFail } from '../../../utils/utils'; -import { ng } from '../../../utils/process'; -import { stripIndents } from 'common-tags'; -import { updateJsonFile } from '../../../utils/project'; - -export default function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return writeMultipleFiles({ - 'src/styles.less': stripIndents` - @import './imported-styles.less'; - body { background-color: blue; } - `, - 'src/imported-styles.less': stripIndents` - p { background-color: red; } - `, - 'src/app/app.component.less': stripIndents` - .outer { - .inner { - background: #fff; - } - } - `}) - .then(() => deleteFile('src/app/app.component.css')) - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.styles = [ - { input: 'src/styles.less' }, - ]; - })) - .then(() => replaceInFile('src/app/app.component.ts', - './app.component.css', './app.component.less')) - .then(() => ng('build', '--extract-css', '--source-map')) - .then(() => expectFileToMatch('dist/test-project/styles.css', - /body\s*{\s*background-color: blue;\s*}/)) - .then(() => expectFileToMatch('dist/test-project/styles.css', - /p\s*{\s*background-color: red;\s*}/)) - .then(() => expectToFail(() => expectFileToMatch('dist/test-project/styles.css', '"mappings":""'))) - .then(() => expectFileToMatch('dist/test-project/main.js', /.outer.*.inner.*background:\s*#[fF]+/)); -} diff --git a/tests/legacy-cli/e2e/tests/build/styles/loaders.ts b/tests/legacy-cli/e2e/tests/build/styles/loaders.ts deleted file mode 100644 index 8f52edc87b3b..000000000000 --- a/tests/legacy-cli/e2e/tests/build/styles/loaders.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - writeMultipleFiles, - deleteFile, - expectFileToMatch, - replaceInFile -} from '../../../utils/fs'; -import { ng } from '../../../utils/process'; -import { stripIndents } from 'common-tags'; -import { updateJsonFile } from '../../../utils/project'; -import { expectToFail } from '../../../utils/utils'; - -export default function () { - return writeMultipleFiles({ - 'src/styles.scss': stripIndents` - @import './imported-styles.scss'; - body { background-color: blue; } - `, - 'src/imported-styles.scss': stripIndents` - p { background-color: red; } - `, - 'src/app/app.component.scss': stripIndents` - .outer { - .inner { - background: #fff; - } - } - `}) - .then(() => deleteFile('src/app/app.component.css')) - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.styles = [ - { input: 'src/styles.scss' }, - ]; - })) - .then(() => replaceInFile('src/app/app.component.ts', - './app.component.css', './app.component.scss')) - .then(() => ng('build')) - .then(() => expectToFail(() => expectFileToMatch('dist/test-project/styles.css', /exports/))) - .then(() => expectToFail(() => expectFileToMatch('dist/test-project/main.js', - /".*module\.exports.*\.outer.*background:/))); -} diff --git a/tests/legacy-cli/e2e/tests/build/styles/material-import.ts b/tests/legacy-cli/e2e/tests/build/styles/material-import.ts deleted file mode 100644 index 03699182c935..000000000000 --- a/tests/legacy-cli/e2e/tests/build/styles/material-import.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - writeMultipleFiles, - replaceInFile -} from '../../../utils/fs'; -import { ng, silentNpm } from '../../../utils/process'; -import { stripIndents } from 'common-tags'; -import { updateJsonFile } from '../../../utils/project'; - -export default function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - const extensions = ['css', 'scss', 'less', 'styl']; - let promise: Promise = Promise.resolve() - .then(() => silentNpm('install', '@angular/material@5.0.4')); - - extensions.forEach(ext => { - promise = promise.then(() => { - return writeMultipleFiles({ - [`src/styles.${ext}`]: stripIndents` - @import "~@angular/material/prebuilt-themes/indigo-pink.css"; - `, - [`src/app/app.component.${ext}`]: stripIndents` - @import "~@angular/material/prebuilt-themes/indigo-pink.css"; - `, - }) - // change files to use preprocessor - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.styles = [ - { input: `src/styles.${ext}` }, - ]; - })) - .then(() => replaceInFile('src/app/app.component.ts', - './app.component.css', `./app.component.${ext}`)) - // run build app - .then(() => ng('build', '--extract-css', '--source-map')) - .then(() => writeMultipleFiles({ - [`src/styles.${ext}`]: stripIndents` - @import "@angular/material/prebuilt-themes/indigo-pink.css"; - `, - [`src/app/app.component.${ext}`]: stripIndents` - @import "@angular/material/prebuilt-themes/indigo-pink.css"; - `, - })) - .then(() => ng('build', '--extract-css')); - }); - }); - - return promise; -} diff --git a/tests/legacy-cli/e2e/tests/build/styles/scss.ts b/tests/legacy-cli/e2e/tests/build/styles/scss.ts deleted file mode 100644 index 60173381142d..000000000000 --- a/tests/legacy-cli/e2e/tests/build/styles/scss.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { - writeMultipleFiles, - deleteFile, - expectFileToMatch, - replaceInFile -} from '../../../utils/fs'; -import { expectToFail } from '../../../utils/utils'; -import { ng } from '../../../utils/process'; -import { stripIndents } from 'common-tags'; -import { updateJsonFile } from '../../../utils/project'; - -export default function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return writeMultipleFiles({ - 'src/styles.scss': stripIndents` - @import './imported-styles.scss'; - body { background-color: blue; } - `, - 'src/imported-styles.scss': stripIndents` - p { background-color: red; } - `, - 'src/app/app.component.scss': stripIndents` - .outer { - .inner { - background: #fff; - } - } - `}) - .then(() => deleteFile('src/app/app.component.css')) - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.styles = [ - { input: 'src/styles.scss' }, - ]; - })) - .then(() => replaceInFile('src/app/app.component.ts', - './app.component.css', './app.component.scss')) - .then(() => ng('build', '--extract-css', '--source-map')) - .then(() => expectFileToMatch('dist/test-project/styles.css', - /body\s*{\s*background-color: blue;\s*}/)) - .then(() => expectFileToMatch('dist/test-project/styles.css', - /p\s*{\s*background-color: red;\s*}/)) - .then(() => expectToFail(() => expectFileToMatch('dist/test-project/styles.css', '"mappings":""'))) - .then(() => expectFileToMatch('dist/test-project/main.js', /.outer.*.inner.*background:\s*#[fF]+/)); -} diff --git a/tests/legacy-cli/e2e/tests/build/styles/stylus.ts b/tests/legacy-cli/e2e/tests/build/styles/stylus.ts deleted file mode 100644 index 83d33074719a..000000000000 --- a/tests/legacy-cli/e2e/tests/build/styles/stylus.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { - writeMultipleFiles, - deleteFile, - expectFileToMatch, - replaceInFile -} from '../../../utils/fs'; -import { expectToFail } from '../../../utils/utils'; -import { ng } from '../../../utils/process'; -import { stripIndents } from 'common-tags'; -import { updateJsonFile } from '../../../utils/project'; - -export default function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return writeMultipleFiles({ - 'src/styles.styl': stripIndents` - @import './imported-styles.styl'; - body { background-color: blue; } - `, - 'src/imported-styles.styl': stripIndents` - p { background-color: red; } - `, - 'src/app/app.component.styl': stripIndents` - .outer { - .inner { - background: #fff; - } - } - `}) - .then(() => deleteFile('src/app/app.component.css')) - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.styles = [ - { input: 'src/styles.styl' }, - ]; - })) - .then(() => replaceInFile('src/app/app.component.ts', - './app.component.css', './app.component.styl')) - .then(() => ng('build', '--extract-css', '--source-map')) - .then(() => expectFileToMatch('dist/test-project/styles.css', - /body\s*{\s*background-color: #00f;\s*}/)) - .then(() => expectFileToMatch('dist/test-project/styles.css', - /p\s*{\s*background-color: #f00;\s*}/)) - .then(() => expectToFail(() => expectFileToMatch('dist/test-project/styles.css', '"mappings":""'))) - .then(() => expectFileToMatch('dist/test-project/main.js', /.outer.*.inner.*background:\s*#[fF]+/)); -} diff --git a/tests/legacy-cli/e2e/tests/build/subresource-integrity.ts b/tests/legacy-cli/e2e/tests/build/subresource-integrity.ts deleted file mode 100644 index 6592fd373154..000000000000 --- a/tests/legacy-cli/e2e/tests/build/subresource-integrity.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { expectFileToMatch } from '../../utils/fs'; -import { ng } from '../../utils/process'; -import { expectToFail } from '../../utils/utils'; - -const integrityRe = /integrity="\w+-[A-Za-z0-9\/\+=]+"/; - -export default async function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - // WEBPACK4_DISABLED - disabled pending a webpack 4 version - return; - - return ng('build') - .then(() => expectToFail(() => - expectFileToMatch('dist/test-project/index.html', integrityRe))) - .then(() => ng('build', '--sri')) - .then(() => expectFileToMatch('dist/test-project/index.html', integrityRe)); -} diff --git a/tests/legacy-cli/e2e/tests/build/ts-paths.ts b/tests/legacy-cli/e2e/tests/build/ts-paths.ts deleted file mode 100644 index d36d5818c822..000000000000 --- a/tests/legacy-cli/e2e/tests/build/ts-paths.ts +++ /dev/null @@ -1,54 +0,0 @@ -import {updateTsConfig} from '../../utils/project'; -import {writeMultipleFiles, appendToFile, createDir, replaceInFile} from '../../utils/fs'; -import {ng} from '../../utils/process'; -import {stripIndents} from 'common-tags'; - - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return updateTsConfig(json => { - json['compilerOptions']['baseUrl'] = './src'; - json['compilerOptions']['paths'] = { - '@shared': [ - 'app/shared' - ], - '@shared/*': [ - 'app/shared/*' - ], - '@root/*': [ - './*' - ] - }; - }) - .then(() => createDir('src/app/shared')) - .then(() => writeMultipleFiles({ - 'src/meaning-too.ts': 'export var meaning = 42;', - 'src/app/shared/meaning.ts': 'export var meaning = 42;', - 'src/app/shared/index.ts': `export * from './meaning'`, - })) - .then(() => replaceInFile('src/app/app.module.ts', './app.component', '@root/app/app.component')) - .then(() => ng('build')) - .then(() => updateTsConfig(json => { - json['compilerOptions']['paths']['*'] = [ - '*', - 'app/shared/*' - ]; - })) - .then(() => appendToFile('src/app/app.component.ts', stripIndents` - import { meaning } from 'app/shared/meaning'; - import { meaning as meaning2 } from '@shared'; - import { meaning as meaning3 } from '@shared/meaning'; - import { meaning as meaning4 } from 'meaning'; - import { meaning as meaning5 } from 'meaning-too'; - - // need to use imports otherwise they are ignored and - // no error is outputted, even if baseUrl/paths don't work - console.log(meaning) - console.log(meaning2) - console.log(meaning3) - console.log(meaning4) - console.log(meaning5) - `)) - .then(() => ng('build')); -} diff --git a/tests/legacy-cli/e2e/tests/build/typescript/typescript-2_7.ts b/tests/legacy-cli/e2e/tests/build/typescript/typescript-2_7.ts deleted file mode 100644 index 0afd67574d41..000000000000 --- a/tests/legacy-cli/e2e/tests/build/typescript/typescript-2_7.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ng, npm } from '../../../utils/process'; -import { updateJsonFile } from '../../../utils/project'; - -export default async function () { - // This test should only be active when the current version of Angular supports multiple - // TypeScript minor versions. - return; - - // Disable the strict TS version check for snapshots - await updateJsonFile('src/tsconfig.app.json', configJson => { - configJson.angularCompilerOptions = { - ...configJson.angularCompilerOptions, - disableTypeScriptVersionCheck: true, - }; - }); - - await npm('install', 'typescript@2.7'); - await ng('build'); - await ng('build', '--prod'); - await npm('install', 'typescript@2.6'); -} diff --git a/tests/legacy-cli/e2e/tests/build/vendor-chunk-symlink-node-module.ts b/tests/legacy-cli/e2e/tests/build/vendor-chunk-symlink-node-module.ts deleted file mode 100644 index 61eae952ea71..000000000000 --- a/tests/legacy-cli/e2e/tests/build/vendor-chunk-symlink-node-module.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {ng} from '../../utils/process'; -import {deleteFile, expectFileToExist, moveFile, symlinkFile} from '../../utils/fs'; - - -// THIS TEST REQUIRES TO MOVE NODE_MODULES AND MOVE IT BACK. -export default function() { - // E2E_DISABLED: temporarily disabled - return Promise.resolve(); - - return Promise.resolve() - .then(() => moveFile('node_modules', '../node_modules')) - .then(() => symlinkFile('../node_modules', 'node_modules', 'dir')) - .then(() => ng('build')) - .then(() => expectFileToExist('dist/test-project/vendor.js')) - // Cleanup - .then(() => { - return deleteFile('node_modules') - .then(() => moveFile('../node_modules', 'node_modules')); - }, (err: any) => { - return deleteFile('node_modules') - .then(() => moveFile('../node_modules', 'node_modules')) - .then(() => { throw err; }); - }) -} diff --git a/tests/legacy-cli/e2e/tests/build/vendor-chunk.ts b/tests/legacy-cli/e2e/tests/build/vendor-chunk.ts deleted file mode 100644 index 8a35d7c870a0..000000000000 --- a/tests/legacy-cli/e2e/tests/build/vendor-chunk.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; -import {expectToFail} from '../../utils/utils'; - - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return ng('build') - .then(() => expectFileToExist('dist/test-project/vendor.js')) - .then(() => ng('build', '--vendor-chunk=false')) - .then(() => expectToFail(() => expectFileToExist('dist/test-project/vendor.js'))); -} diff --git a/tests/legacy-cli/e2e/tests/build/watch.ts b/tests/legacy-cli/e2e/tests/build/watch.ts deleted file mode 100644 index cb3bf7390bb9..000000000000 --- a/tests/legacy-cli/e2e/tests/build/watch.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { - killAllProcesses, - exec, - waitForAnyProcessOutputToMatch, - execAndWaitForOutputToMatch -} from '../../utils/process'; -import { expectToFail } from '../../utils/utils'; - - -const webpackGoodRegEx = /: Compiled successfully./; - -export default function () { - // TODO(architect): This test is behaving oddly both here and in devkit/build-angular. - // It seems to be because of file watchers. - return; - - if (process.platform.startsWith('win')) { - return Promise.resolve(); - } - - return execAndWaitForOutputToMatch('ng', ['serve'], webpackGoodRegEx) - // Should trigger a rebuild. - .then(() => exec('touch', 'src/main.ts')) - .then(() => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 10000)) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }) - .then(() => execAndWaitForOutputToMatch('ng', ['serve', '--no-watch'], webpackGoodRegEx)) - // Should not trigger a rebuild when not watching files. - .then(() => exec('touch', 'src/main.ts')) - .then(() => expectToFail(() => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 10000))) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }) -} diff --git a/tests/legacy-cli/e2e/tests/commands/add/add.ts b/tests/legacy-cli/e2e/tests/commands/add/add.ts deleted file mode 100644 index 87aee9c82441..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/add/add.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { expectFileToExist, expectFileToMatch, rimraf } from '../../../utils/fs'; -import { ng } from '../../../utils/process'; - - -export default async function () { - await ng('add', '@angular-devkit-tests/ng-add-simple'); - await expectFileToMatch('package.json', /@angular-devkit-tests\/ng-add-simple/); - await expectFileToExist('ng-add-test'); - await rimraf('node_modules/@angular-devkit-tests/ng-add-simple'); -} diff --git a/tests/legacy-cli/e2e/tests/commands/add/base.ts b/tests/legacy-cli/e2e/tests/commands/add/base.ts deleted file mode 100644 index 08a1f098f6a6..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/add/base.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { assetDir } from '../../../utils/assets'; -import { expectFileToExist, symlinkFile } from '../../../utils/fs'; -import { ng } from '../../../utils/process'; -import { expectToFail } from '../../../utils/utils'; - - -export default async function () { - await symlinkFile(assetDir('add-collection'), `./node_modules/add-collection`, 'dir'); - - await ng('add', 'add-collection'); - await expectFileToExist('empty-file'); - - await ng('add', 'add-collection', '--name=blah'); - await expectFileToExist('blah'); - - // TODO: reenable this check when schematics fail the CLI command. - await expectToFail(() => ng('add', 'add-collection')); // File already exists. -} diff --git a/tests/legacy-cli/e2e/tests/commands/build/build-outdir.ts b/tests/legacy-cli/e2e/tests/commands/build/build-outdir.ts deleted file mode 100644 index 38de272bdeff..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/build/build-outdir.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {ng} from '../../../utils/process'; -import {updateJsonFile} from '../../../utils/project'; -import {expectToFail} from '../../../utils/utils'; - -export default function() { - // TODO(architect): This isn't working correctly in devkit/build-angular, due to module resolution. - return; - - return Promise.resolve() - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.outputPath = './'; - })) - .then(() => expectToFail(() => ng('build'))) - .then(() => expectToFail(() => ng('serve'))) - .then(() => expectToFail(() => ng('eject'))); -} diff --git a/tests/legacy-cli/e2e/tests/commands/config/config-get.ts b/tests/legacy-cli/e2e/tests/commands/config/config-get.ts deleted file mode 100644 index a5abd6ad6fed..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/config/config-get.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ng } from '../../../utils/process'; -import { expectToFail } from '../../../utils/utils'; - - -export default function() { - return Promise.resolve() - .then(() => expectToFail(() => ng('config', 'schematics.@schematics/angular.component.inlineStyle'))) - .then(() => ng('config', 'schematics.@schematics/angular.component.inlineStyle', 'false')) - .then(() => ng('config', 'schematics.@schematics/angular.component.inlineStyle')) - .then(({ stdout }) => { - if (!stdout.match(/false\n?/)) { - throw new Error(`Expected "false", received "${JSON.stringify(stdout)}".`); - } - }) - .then(() => ng('config', 'schematics.@schematics/angular.component.inlineStyle', 'true')) - .then(() => ng('config', 'schematics.@schematics/angular.component.inlineStyle')) - .then(({ stdout }) => { - if (!stdout.match(/true\n?/)) { - throw new Error(`Expected "true", received "${JSON.stringify(stdout)}".`); - } - }) - .then(() => ng('config', 'schematics.@schematics/angular.component.inlineStyle', 'false')); -} diff --git a/tests/legacy-cli/e2e/tests/commands/config/config-global.ts b/tests/legacy-cli/e2e/tests/commands/config/config-global.ts deleted file mode 100644 index 8327836fc500..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/config/config-global.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { homedir } from 'os'; -import * as path from 'path'; -import { deleteFile, expectFileToExist } from '../../../utils/fs'; -import { ng } from '../../../utils/process'; -import { expectToFail } from '../../../utils/utils'; - - -export default async function() { - await expectToFail(() => ng( - 'config', - '--global', - 'schematics.@schematics/angular.component.inlineStyle', - )); - - await ng('config', '--global', 'schematics.@schematics/angular.component.inlineStyle', 'false'); - let output = await ng( - 'config', - '--global', - 'schematics.@schematics/angular.component.inlineStyle', - ); - if (!output.stdout.match(/false\n?/)) { - throw new Error(`Expected "false", received "${JSON.stringify(output.stdout)}".`); - } - - // This test requires schema querying capabilities - // .then(() => expectToFail(() => { - // return ng('config', '--global', 'schematics.@schematics/angular.component.inlineStyle', 'INVALID_BOOLEAN'); - // })) - - const cwd = process.cwd(); - process.chdir('/'); - try { - await ng('config', '--global', 'schematics.@schematics/angular.component.inlineStyle', 'true'); - } finally { - process.chdir(cwd); - } - - output = await ng('config', '--global', 'schematics.@schematics/angular.component.inlineStyle'); - if (!output.stdout.match(/true\n?/)) { - throw new Error(`Expected "true", received "${JSON.stringify(output.stdout)}".`); - } - - await expectToFail(() => ng('config', '--global', 'cli.warnings.notreal', 'true')); - - await ng('config', '--global', 'cli.warnings.versionMismatch', 'false'); - await expectFileToExist(path.join(homedir(), '.angular-config.json')); - await deleteFile(path.join(homedir(), '.angular-config.json')); -} diff --git a/tests/legacy-cli/e2e/tests/commands/config/config-set-enum-check.ts b/tests/legacy-cli/e2e/tests/commands/config/config-set-enum-check.ts deleted file mode 100644 index aabf758930a9..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/config/config-set-enum-check.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ng } from '../../../utils/process'; -import { expectToFail } from '../../../utils/utils'; - -export default async function() { - - // These tests require schema querying capabilities - // .then(() => expectToFail( - // () => ng('config', 'schematics.@schematics/angular.component.aaa', 'bbb')), - // ) - // .then(() => expectToFail(() => ng( - // 'config', - // 'schematics.@schematics/angular.component.viewEncapsulation', - // 'bbb', - // ))) - - await ng( - 'config', - 'schematics.@schematics/angular.component.viewEncapsulation', - 'Emulated', - ); -} diff --git a/tests/legacy-cli/e2e/tests/commands/config/config-set-prefix.ts b/tests/legacy-cli/e2e/tests/commands/config/config-set-prefix.ts deleted file mode 100644 index 904050649129..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/config/config-set-prefix.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ng } from '../../../utils/process'; -import { expectToFail } from '../../../utils/utils'; - -export default function() { - return Promise.resolve() - .then(() => expectToFail(() => ng('config', 'schematics.@schematics/angular.component.prefix'))) - .then(() => ng('config', 'schematics.@schematics/angular.component.prefix' , 'new-prefix')) - .then(() => ng('config', 'schematics.@schematics/angular.component.prefix')) - .then(({ stdout }) => { - if (!stdout.match(/new-prefix/)) { - throw new Error(`Expected "new-prefix", received "${JSON.stringify(stdout)}".`); - } - }); -} diff --git a/tests/legacy-cli/e2e/tests/commands/config/config-set.ts b/tests/legacy-cli/e2e/tests/commands/config/config-set.ts deleted file mode 100644 index 436c6dff2865..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/config/config-set.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {ng} from '../../../utils/process'; -import {expectToFail} from '../../../utils/utils'; - -export default function() { - return Promise.resolve() - .then(() => expectToFail(() => ng('config', 'cli.warnings.zzzz'))) - .then(() => ng('config', 'cli.warnings.typescriptMismatch' , 'false')) - .then(() => ng('config', 'cli.warnings.typescriptMismatch')) - .then(({ stdout }) => { - if (!stdout.match(/false/)) { - throw new Error(`Expected "false", received "${JSON.stringify(stdout)}".`); - } - }) - .then(() => ng('config', 'cli.packageManager' , 'yarn')) - .then(() => ng('config', 'cli.packageManager')) - .then(({ stdout }) => { - if (!stdout.match(/yarn/)) { - throw new Error(`Expected "yarn", received "${JSON.stringify(stdout)}".`); - } - }); -} diff --git a/tests/legacy-cli/e2e/tests/commands/config/get-set-deprecation.ts b/tests/legacy-cli/e2e/tests/commands/config/get-set-deprecation.ts deleted file mode 100644 index ad9d3f106d17..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/config/get-set-deprecation.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {ng} from '../../../utils/process'; - - -export default function() { - const depRegEx = /get\/set have been deprecated in favor of the config command\./; - return Promise.resolve() - .then(() => ng('get')) - .then(({ stderr }) => { - if (!stderr.match(depRegEx)) { - throw new Error(`Expected deprecation warning.`); - } - }) - .then(() => ng('set')) - .then(({ stderr }) => { - if (!stderr.match(depRegEx)) { - throw new Error(`Expected deprecation warning.`); - } - }); -} diff --git a/tests/legacy-cli/e2e/tests/commands/help/help-hidden.ts b/tests/legacy-cli/e2e/tests/commands/help/help-hidden.ts deleted file mode 100644 index 5f88a787257c..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/help/help-hidden.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { oneLine } from 'common-tags'; - -import { silentNg } from '../../../utils/process'; - - -export default function() { - return Promise.resolve() - .then(() => silentNg('--help')) - .then(({ stdout }) => { - if (stdout.match(/(easter-egg)|(ng make-this-awesome)|(ng init)/)) { - throw new Error(oneLine` - Expected to not match "(easter-egg)|(ng make-this-awesome)|(ng init)" - in help output. - `); - } - }) - .then(() => silentNg('--help', 'new')) - .then(({ stdout }) => { - if (stdout.match(/--link-cli/)) { - throw new Error(oneLine` - Expected to not match "--link-cli" - in help output. - `); - } - }) -} diff --git a/tests/legacy-cli/e2e/tests/commands/help/help-json.ts b/tests/legacy-cli/e2e/tests/commands/help/help-json.ts deleted file mode 100644 index af4f18872990..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/help/help-json.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { silentNg } from '../../../utils/process'; - - -export default async function() { - const commands = require('@angular/cli/commands.json'); - for (const commandName of Object.keys(commands)) { - const { stdout } = await silentNg(commandName, '--help=json'); - - if (stdout.trim()) { - JSON.parse(stdout); - } else { - console.warn(`No JSON output for command [${commandName}].`); - } - } -} diff --git a/tests/legacy-cli/e2e/tests/commands/help/help-option-command.ts b/tests/legacy-cli/e2e/tests/commands/help/help-option-command.ts deleted file mode 100644 index c6782765b839..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/help/help-option-command.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {silentNg} from '../../../utils/process'; - - -export default function() { - return Promise.resolve() - .then(() => silentNg('--help', 'build')); -} diff --git a/tests/legacy-cli/e2e/tests/commands/help/help-option.ts b/tests/legacy-cli/e2e/tests/commands/help/help-option.ts deleted file mode 100644 index 03b96b5758d9..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/help/help-option.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {silentNg} from '../../../utils/process'; - - -export default function() { - return Promise.resolve() - .then(() => silentNg('--help')) - .then(() => process.chdir('/')) - .then(() => silentNg('--help')); -} diff --git a/tests/legacy-cli/e2e/tests/commands/help/help.ts b/tests/legacy-cli/e2e/tests/commands/help/help.ts deleted file mode 100644 index f326f6a81ff8..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/help/help.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {silentNg} from '../../../utils/process'; - - -export default function() { - return Promise.resolve() - .then(() => silentNg('help')) - .then(() => process.chdir('/')) - .then(() => silentNg('help')); -} diff --git a/tests/legacy-cli/e2e/tests/commands/new/check-yarn.ts b/tests/legacy-cli/e2e/tests/commands/new/check-yarn.ts deleted file mode 100644 index 9dd3bf411b4d..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/new/check-yarn.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {ng} from '../../../utils/process'; -import {getGlobalVariable} from '../../../utils/env'; - -const yarnRegEx = /You can `ng config -g cli.packageManager yarn`./; - -export default function() { - return Promise.resolve() - .then(() => process.chdir(getGlobalVariable('tmp-root'))) - .then(() => ng('config', '--global', 'packageManager', 'default')) - .then(() => ng('new', 'foo', '--version=1.6.8')) - .then(({ stdout }) => { - // Assuming yarn is installed and checking for message with yarn. - if (!stdout.match(yarnRegEx)) { - throw new Error('Should display message to use yarn packageManager'); - } - }); -} diff --git a/tests/legacy-cli/e2e/tests/commands/new/existing-directory.ts b/tests/legacy-cli/e2e/tests/commands/new/existing-directory.ts deleted file mode 100644 index 232874ff5ae4..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/new/existing-directory.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as fs from 'fs'; -import { promisify } from 'util'; -import {ng} from '../../../utils/process'; -import {getGlobalVariable} from '../../../utils/env'; - -const mkdir = promisify(fs.mkdir); - - -export default function() { - return Promise.resolve() - .then(() => process.chdir(getGlobalVariable('tmp-root'))) - .then(() => mkdir('empty-directory')) - .then(() => ng('new', 'foo', '--directory=empty-directory', '--skip-install', '--skip-git')); -} diff --git a/tests/legacy-cli/e2e/tests/commands/new/new-minimal.ts b/tests/legacy-cli/e2e/tests/commands/new/new-minimal.ts deleted file mode 100644 index c12d47fd6101..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/new/new-minimal.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {ng} from '../../../utils/process'; -import {createProject} from '../../../utils/project'; -import {expectFileNotToExist, expectFileToMatch} from '../../../utils/fs'; -import {expectToFail} from '../../../utils/utils'; - - -export default function() { - // TODO(architect): re-enable after figuring out how a new minimal project looks like. - return Promise.resolve() - .then(() => createProject('minimal-project', '--minimal')) - .then(() => expectFileNotToExist('.editorconfig')) - .then(() => expectFileNotToExist('README.md')) - .then(() => expectFileNotToExist('karma.conf.js')) - .then(() => expectFileNotToExist('protractor.conf.js')) - .then(() => expectFileNotToExist('src/test.ts')) - .then(() => expectFileNotToExist('src/tsconfig.spec.json')) - .then(() => expectFileNotToExist('tslint.json')) - .then(() => expectFileNotToExist('src/app/app.component.html')) - .then(() => expectFileNotToExist('src/app/app.component.css')) - .then(() => expectFileNotToExist('src/app/app.component.spec.ts')) - .then(() => expectFileNotToExist('src/app/favicon.ico')) - - .then(() => expectToFail(() => expectFileToMatch('package.json', '"protractor":'))) - .then(() => expectToFail(() => expectFileToMatch('package.json', '"karma":'))) - .then(() => expectToFail(() => expectFileToMatch('package.json', '"jasmine-core":'))) - - // Try to run a build. - .then(() => ng('build')); -} diff --git a/tests/legacy-cli/e2e/tests/commands/new/new-routing.ts b/tests/legacy-cli/e2e/tests/commands/new/new-routing.ts deleted file mode 100644 index 5addb021d8a0..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/new/new-routing.ts +++ /dev/null @@ -1,11 +0,0 @@ -import {ng} from '../../../utils/process'; -import {createProject} from '../../../utils/project'; - - -export default function() { - return Promise.resolve() - .then(() => createProject('routing-project', '--routing')) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/commands/new/new-skip-commit.ts b/tests/legacy-cli/e2e/tests/commands/new/new-skip-commit.ts deleted file mode 100644 index adedb9dc2ac4..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/new/new-skip-commit.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {ng} from '../../../utils/process'; -import {createProject} from '../../../utils/project'; -import {expectToFail} from '../../../utils/utils'; -import {expectGitToBeClean} from '../../../utils/git'; - - -export default function() { - return Promise.resolve() - .then(() => createProject('new-project', '--skip-commit')) - .then(() => expectToFail(() => expectGitToBeClean())) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/commands/new/new-skip-tests.ts b/tests/legacy-cli/e2e/tests/commands/new/new-skip-tests.ts deleted file mode 100644 index 95dde0a38e88..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/new/new-skip-tests.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {createProject} from '../../../utils/project'; -import {expectFileNotToExist} from '../../../utils/fs'; - - -export default function() { - return Promise.resolve() - .then(() => createProject('new-project-skip-tests', '--skip-tests')) - .then(() => expectFileNotToExist('src/app/app.component.spec.ts')); -} diff --git a/tests/legacy-cli/e2e/tests/commands/new/new-style.ts b/tests/legacy-cli/e2e/tests/commands/new/new-style.ts deleted file mode 100644 index 62958ef392de..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/new/new-style.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {ng} from '../../../utils/process'; -import {createProject} from '../../../utils/project'; -import {expectFileToExist} from '../../../utils/fs'; - - -export default function() { - return Promise.resolve() - .then(() => ng('config', 'defaults.styleExt', 'scss', '--global')) - .then(() => createProject('style-project')) - .then(() => expectFileToExist('src/app/app.component.scss')) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/commands/serve/serve-path.ts b/tests/legacy-cli/e2e/tests/commands/serve/serve-path.ts deleted file mode 100644 index 2075b1acd3c0..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/serve/serve-path.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { request } from '../../../utils/http'; -import { killAllProcesses } from '../../../utils/process'; -import { ngServe } from '../../../utils/project'; - -export default function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return Promise.resolve() - .then(() => ngServe('--serve-path', 'test/')) - .then(() => request('http://localhost:4200/test')) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => request('http://localhost:4200/test/abc')) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) - // .then(() => ngServe('--base-href', 'test/')) - // .then(() => request('http://localhost:4200/test')) - // .then(body => { - // if (!body.match(/<\/app-root>/)) { - // throw new Error('Response does not match expected value.'); - // } - // }) - // .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }); -} diff --git a/tests/legacy-cli/e2e/tests/commands/unknown-option.ts b/tests/legacy-cli/e2e/tests/commands/unknown-option.ts deleted file mode 100644 index cc1e994dbae9..000000000000 --- a/tests/legacy-cli/e2e/tests/commands/unknown-option.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { execAndWaitForOutputToMatch, ng } from '../../utils/process'; -import { expectToFail } from '../../utils/utils'; - -export default async function() { - await expectToFail(() => ng('build', '--notanoption')); - - await execAndWaitForOutputToMatch( - 'ng', - [ 'build', '--notanoption' ], - /Unknown option: '--notanoption'/, - ); - - await expectToFail(() => execAndWaitForOutputToMatch( - 'ng', - [ 'build', '--notanoption' ], - /should NOT have additional properties\(notanoption\)./, - )); -} diff --git a/tests/legacy-cli/e2e/tests/experimental/ivy.ts b/tests/legacy-cli/e2e/tests/experimental/ivy.ts deleted file mode 100644 index 794b6c7b6569..000000000000 --- a/tests/legacy-cli/e2e/tests/experimental/ivy.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @license - * 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 - */ -import { request } from '../../utils/http'; -import { killAllProcesses } from '../../utils/process'; -import { createProject, ngServe } from '../../utils/project'; - -export default async function() { - try { - await createProject('ivy-project', '--experimental-ivy'); - - await ngServe('--prod'); - - // Verify the index.html - const body = await request('http://localhost:4200/'); - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - - // Verify it's Ivy. - const mainUrlMatch = body.match(/src="(main\.[a-z0-9]{0,32}\.js)"/); - const mainUrl = mainUrlMatch && mainUrlMatch[1]; - const main = await request('http://localhost:4200/' + mainUrl); - - if (!main.match(/ngComponentDef\s*=/)) { - throw new Error('Ivy could not be found.'); - } - if (main.match(/ngDevMode/)) { - throw new Error('NgDevMode was not tree shaken away.'); - } - } finally { - await killAllProcesses(); - } -} diff --git a/tests/legacy-cli/e2e/tests/generate/application/application-basic.ts b/tests/legacy-cli/e2e/tests/generate/application/application-basic.ts deleted file mode 100644 index ff96506e53df..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/application/application-basic.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {ng} from '../../../utils/process'; -import { expectFileToMatch } from '../../../utils/fs'; -import { useCIChrome } from '../../../utils/project'; - - -export default function() { - return ng('generate', 'application', 'app2') - .then(() => expectFileToMatch('angular.json', /\"app2\":/)) - .then(() => useCIChrome('projects/app2')) - .then(() => useCIChrome('projects/app2-e2e')) - .then(() => ng('test', 'app2', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/class.ts b/tests/legacy-cli/e2e/tests/generate/class.ts deleted file mode 100644 index 42bbca037df5..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/class.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; - - -export default function() { - const projectDir = join('src', 'app'); - - return ng('generate', 'class', 'test-class', '--spec') - .then(() => expectFileToExist(projectDir)) - .then(() => expectFileToExist(join(projectDir, 'test-class.ts'))) - .then(() => expectFileToExist(join(projectDir, 'test-class.spec.ts'))) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/component/component-basic.ts b/tests/legacy-cli/e2e/tests/generate/component/component-basic.ts deleted file mode 100644 index 7c39f144d00d..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/component/component-basic.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist, expectFileToMatch} from '../../../utils/fs'; - - -export default function() { - const projectDir = join('src', 'app'); - const componentDir = join(projectDir, 'test-component'); - - const importCheck = - `import { TestComponentComponent } from './test-component/test-component.component';`; - return ng('generate', 'component', 'test-component') - .then(() => expectFileToExist(componentDir)) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.ts'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.spec.ts'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.html'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.css'))) - .then(() => expectFileToMatch(join(projectDir, 'app.module.ts'), importCheck)) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/component/component-child-dir.ts b/tests/legacy-cli/e2e/tests/generate/component/component-child-dir.ts deleted file mode 100644 index 660e1e119c8c..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/component/component-child-dir.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {createDir, expectFileToExist} from '../../../utils/fs'; - - -export default function() { - const subDir = 'sub-dir'; - const componentDir = join('src', 'app', subDir, 'test-component'); - - return Promise.resolve() - .then(() => process.chdir('src')) - .then(() => process.chdir('app')) - .then(() => createDir(subDir)) - .then(() => process.chdir(subDir)) - .then(() => ng('generate', 'component', 'test-component')) - .then(() => process.chdir('../../..')) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.ts'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.spec.ts'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.html'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.css'))) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/component/component-duplicate.ts b/tests/legacy-cli/e2e/tests/generate/component/component-duplicate.ts deleted file mode 100644 index a90043c1fc17..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/component/component-duplicate.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ng } from '../../../utils/process'; -import { expectToFail } from '../../../utils/utils'; -import { oneLine } from 'common-tags'; - -export default function () { - return ng('generate', 'component', 'test-component') - .then((output) => { - // tslint:disable-next-line:max-line-length - if (!output.stdout.match(/UPDATE src[\\|\/]app[\\|\/]app.module.ts/)) { - throw new Error(oneLine` - Expected to match - "UPDATE src/app.module.ts" - in ${output.stdout}.`); - } - }) - .then(() => expectToFail(() => ng('generate', 'component', 'test-component'))); -} diff --git a/tests/legacy-cli/e2e/tests/generate/component/component-flag-case.ts b/tests/legacy-cli/e2e/tests/generate/component/component-flag-case.ts deleted file mode 100644 index a759ae01832a..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/component/component-flag-case.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; - - -export default function() { - // TODO:BREAKING CHANGE... NO LONGER SUPPORTED - return Promise.resolve(); - const compDir = join('projects', 'test-project', 'src', 'test'); - - return Promise.resolve() - .then(() => ng('generate', 'component', 'test', - '--change-detection', 'onpush', - '--view-encapsulation', 'emulated')) - .then(() => expectFileToMatch(join(compDir, 'test.component.ts'), - /changeDetection: ChangeDetectionStrategy.OnPush/)) - .then(() => expectFileToMatch(join(compDir, 'test.component.ts'), - /encapsulation: ViewEncapsulation.Emulated/)); -} diff --git a/tests/legacy-cli/e2e/tests/generate/component/component-flat.ts b/tests/legacy-cli/e2e/tests/generate/component/component-flat.ts deleted file mode 100644 index 448a6aef4df3..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/component/component-flat.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist} from '../../../utils/fs'; -import {updateJsonFile} from '../../../utils/project'; - - -export default function() { - const appDir = join('src', 'app'); - return Promise.resolve() - .then(() => updateJsonFile('angular.json', configJson => { - configJson.projects['test-project'].schematics = { - '@schematics/angular:component': { flat: true } - }; - })) - .then(() => ng('generate', 'component', 'test-component')) - .then(() => expectFileToExist(appDir)) - .then(() => expectFileToExist(join(appDir, 'test-component.component.ts'))) - .then(() => expectFileToExist(join(appDir, 'test-component.component.spec.ts'))) - .then(() => expectFileToExist(join(appDir, 'test-component.component.html'))) - .then(() => expectFileToExist(join(appDir, 'test-component.component.css'))) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/component/component-in-existing-module-dir.ts b/tests/legacy-cli/e2e/tests/generate/component/component-in-existing-module-dir.ts deleted file mode 100644 index bb98447bb4a1..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/component/component-in-existing-module-dir.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { join } from 'path'; -import { ng } from '../../../utils/process'; -import { expectFileToMatch } from '../../../utils/fs'; - -export default function () { - const modulePath = join('src', 'app', 'foo', 'foo.module.ts'); - - return Promise.resolve() - .then(() => ng('generate', 'module', 'foo')) - .then(() => ng('generate', 'component', 'foo')) - .then(() => expectFileToMatch(modulePath, /import { FooComponent } from '.\/foo.component'/)); -} diff --git a/tests/legacy-cli/e2e/tests/generate/component/component-inline-template.ts b/tests/legacy-cli/e2e/tests/generate/component/component-inline-template.ts deleted file mode 100644 index f76409820a1a..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/component/component-inline-template.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist} from '../../../utils/fs'; -import {updateJsonFile} from '../../../utils/project'; -import { expectToFail } from '../../../utils/utils'; - - -// tslint:disable:max-line-length -export default function() { - const componentDir = join('src', 'app', 'test-component'); - return Promise.resolve() - .then(() => updateJsonFile('angular.json', configJson => { - configJson.projects['test-project'].schematics = { - '@schematics/angular:component': { inlineTemplate: true } - }; - })) - .then(() => ng('generate', 'component', 'test-component')) - .then(() => expectFileToExist(componentDir)) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.ts'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.spec.ts'))) - .then(() => expectToFail(() => expectFileToExist(join(componentDir, 'test-component.component.html')))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.css'))) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/component/component-module-2.ts b/tests/legacy-cli/e2e/tests/generate/component/component-module-2.ts deleted file mode 100644 index 3036083e9241..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/component/component-module-2.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; - - -export default function() { - const root = process.cwd(); - const modulePath = join(root, 'src', 'app', - 'admin', 'module', 'module.module.ts'); - - return Promise.resolve() - .then(() => ng('generate', 'module', 'admin/module')) - .then(() => ng('generate', 'component', 'other/test-component', '--module', 'admin/module')) - .then(() => expectFileToMatch(modulePath, - new RegExp(/import { TestComponentComponent } /.source + - /from '..\/..\/other\/test-component\/test-component.component'/.source))) - - // Try to run the unit tests. - .then(() => ng('build')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/component/component-module-export.ts b/tests/legacy-cli/e2e/tests/generate/component/component-module-export.ts deleted file mode 100644 index c17e7f051468..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/component/component-module-export.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; - - -export default function() { - const modulePath = join('src', 'app', 'app.module.ts'); - - return ng('generate', 'component', 'test-component', '--export') - .then(() => expectFileToMatch(modulePath, 'exports: [TestComponentComponent]')) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/component/component-module-fail.ts b/tests/legacy-cli/e2e/tests/generate/component/component-module-fail.ts deleted file mode 100644 index f4efd1eb8ff6..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/component/component-module-fail.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {ng} from '../../../utils/process'; -import {expectToFail} from '../../../utils/utils'; - - -export default function() { - return Promise.resolve() - .then(() => expectToFail(() => - ng('generate', 'component', 'test-component', '--module', 'app.moduleXXX.ts'))); -} diff --git a/tests/legacy-cli/e2e/tests/generate/component/component-module.ts b/tests/legacy-cli/e2e/tests/generate/component/component-module.ts deleted file mode 100644 index 53f6c161532a..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/component/component-module.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; - - -export default function() { - const root = process.cwd(); - // projects/ test-project/ src/ app.module.ts - const modulePath = join('src', 'app', 'app.module.ts'); - - return ng('generate', 'component', 'test-component', '--module', 'app.module.ts') - .then(() => expectFileToMatch(modulePath, - /import { TestComponentComponent } from '.\/test-component\/test-component.component'/)) - - .then(() => process.chdir(join(root, 'src', 'app'))) - .then(() => ng('generate', 'component', 'test-component2', '--module', 'app.module.ts')) - .then(() => process.chdir('../..')) - .then(() => expectFileToMatch(modulePath, - /import { TestComponent2Component } from '.\/test-component2\/test-component2.component'/)) - - // Try to run the unit tests. - .then(() => ng('build')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/component/component-not-flat.ts b/tests/legacy-cli/e2e/tests/generate/component/component-not-flat.ts deleted file mode 100644 index d25936fb6e4f..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/component/component-not-flat.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist} from '../../../utils/fs'; -import {updateJsonFile} from '../../../utils/project'; - - -export default function() { - const componentDir = join('src', 'app', 'test-component'); - - return Promise.resolve() - .then(() => updateJsonFile('angular.json', configJson => { - configJson.projects['test-project'].schematics = { - '@schematics/angular:component': { flat: false } - }; - })) - .then(() => ng('generate', 'component', 'test-component')) - .then(() => expectFileToExist(componentDir)) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.ts'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.spec.ts'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.html'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.css'))) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/component/component-path-case.ts b/tests/legacy-cli/e2e/tests/generate/component/component-path-case.ts deleted file mode 100644 index 2a53a3446847..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/component/component-path-case.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist} from '../../../utils/fs'; - - -export default function() { - const upperDirs = join('non', 'existing', 'dir'); - const rootDir = join('src', 'app', upperDirs); - - const componentDir = join(rootDir, 'test-component'); - const componentTwoDir = join(rootDir, 'test-component-two'); - - return ng('generate', 'component', `${upperDirs}/test-component`) - .then(() => expectFileToExist(componentDir)) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.ts'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.spec.ts'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.html'))) - .then(() => expectFileToExist(join(componentDir, 'test-component.component.css'))) - .then(() => ng('generate', 'component', `${upperDirs}/Test-Component-Two`)) - .then(() => expectFileToExist(join(componentTwoDir, 'test-component-two.component.ts'))) - .then(() => expectFileToExist(join(componentTwoDir, 'test-component-two.component.spec.ts'))) - .then(() => expectFileToExist(join(componentTwoDir, 'test-component-two.component.html'))) - .then(() => expectFileToExist(join(componentTwoDir, 'test-component-two.component.css'))) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/component/component-prefix.ts b/tests/legacy-cli/e2e/tests/generate/component/component-prefix.ts deleted file mode 100644 index f63dece7672a..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/component/component-prefix.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; -import { updateJsonFile } from '../../../utils/project'; - - -export default function() { - const testCompDir = join('src', 'app', 'test-component'); - const aliasCompDir = join('src', 'app', 'alias'); - - return Promise.resolve() - .then(() => updateJsonFile('angular.json', configJson => { - configJson.projects['test-project'].schematics = { - '@schematics/angular:component': { prefix: 'pre' } - }; - })) - .then(() => ng('generate', 'component', 'test-component')) - .then(() => expectFileToMatch(join(testCompDir, 'test-component.component.ts'), - /selector: 'pre-/)) - .then(() => ng('g', 'c', 'alias')) - .then(() => expectFileToMatch(join(aliasCompDir, 'alias.component.ts'), - /selector: 'pre-/)) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/directive/directive-basic.ts b/tests/legacy-cli/e2e/tests/generate/directive/directive-basic.ts deleted file mode 100644 index b76308f26da3..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/directive/directive-basic.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {ng} from '../../../utils/process'; -import {join} from 'path'; -import {expectFileToExist} from '../../../utils/fs'; - - -export default function() { - const directiveDir = join('src', 'app'); - return ng('generate', 'directive', 'test-directive') - .then(() => expectFileToExist(join(directiveDir, 'test-directive.directive.ts'))) - .then(() => expectFileToExist(join(directiveDir, 'test-directive.directive.spec.ts'))) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/directive/directive-in-existing-module-dir.ts b/tests/legacy-cli/e2e/tests/generate/directive/directive-in-existing-module-dir.ts deleted file mode 100644 index 7dd270fe81a1..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/directive/directive-in-existing-module-dir.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { join } from 'path'; -import { ng } from '../../../utils/process'; -import { expectFileToMatch } from '../../../utils/fs'; - -export default function () { - const modulePath = join('src', 'app', 'foo', 'foo.module.ts'); - - return Promise.resolve() - .then(() => ng('generate', 'module', 'foo')) - .then(() => ng('generate', 'directive', 'foo', '--no-flat')) - .then(() => expectFileToMatch(modulePath, /import { FooDirective } from '.\/foo.directive'/)); -} diff --git a/tests/legacy-cli/e2e/tests/generate/directive/directive-module-export.ts b/tests/legacy-cli/e2e/tests/generate/directive/directive-module-export.ts deleted file mode 100644 index 2e1499f2d0e2..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/directive/directive-module-export.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; - - -export default function() { - const modulePath = join('src', 'app', 'app.module.ts'); - - return ng('generate', 'directive', 'test-directive', '--export') - .then(() => expectFileToMatch(modulePath, 'exports: [TestDirectiveDirective]')) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/directive/directive-module-fail.ts b/tests/legacy-cli/e2e/tests/generate/directive/directive-module-fail.ts deleted file mode 100644 index 05ff8d98f1c9..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/directive/directive-module-fail.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {ng} from '../../../utils/process'; -import {expectToFail} from '../../../utils/utils'; - -export default function() { - return Promise.resolve() - .then(() => expectToFail(() => - ng('generate', 'directive', 'test-directive', '--module', 'app.moduleXXX.ts'))); -} diff --git a/tests/legacy-cli/e2e/tests/generate/directive/directive-module.ts b/tests/legacy-cli/e2e/tests/generate/directive/directive-module.ts deleted file mode 100644 index 5585253c8fac..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/directive/directive-module.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; - - -export default function() { - const modulePath = join('src', 'app', 'app.module.ts'); - - return ng('generate', 'directive', 'test-directive', '--module', 'app.module.ts') - .then(() => expectFileToMatch(modulePath, - /import { TestDirectiveDirective } from '.\/test-directive.directive'/)) - - .then(() => process.chdir(join('src', 'app'))) - .then(() => ng('generate', 'directive', 'test-directive2', '--module', 'app.module.ts')) - .then(() => process.chdir('../..')) - .then(() => expectFileToMatch(modulePath, - /import { TestDirective2Directive } from '.\/test-directive2.directive'/)) - - // Try to run the unit tests. - .then(() => ng('build')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/directive/directive-prefix.ts b/tests/legacy-cli/e2e/tests/generate/directive/directive-prefix.ts deleted file mode 100644 index 1f480e69cdef..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/directive/directive-prefix.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; -import { updateJsonFile } from '../../../utils/project'; - - -export default function() { - const directiveDir = join('src', 'app'); - - return Promise.resolve() - .then(() => updateJsonFile('angular.json', configJson => { - configJson.schematics = { - '@schematics/angular:directive': { prefix: 'preW' } - }; - })) - .then(() => ng('generate', 'directive', 'test2-directive')) - .then(() => expectFileToMatch(join(directiveDir, 'test2-directive.directive.ts'), - /selector: '\[preW/)) - .then(() => updateJsonFile('angular.json', configJson => { - configJson.projects['test-project'].schematics = { - '@schematics/angular:directive': { prefix: 'preP' } - }; - })) - .then(() => process.chdir('e2e/src')) - .then(() => ng('generate', 'directive', '--skip-import', 'test3-directive')) - .then(() => process.chdir('../..')) - .then(() => expectFileToMatch(join('e2e', 'src', 'test3-directive.directive.ts'), - /selector: '\[preW/)) - .then(() => process.chdir('src/app')) - .then(() => ng('generate', 'directive', 'test-directive')) - .then(() => process.chdir('../..')) - .then(() => expectFileToMatch(join(directiveDir, 'test-directive.directive.ts'), - /selector: '\[preP/)) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/generate-error.ts b/tests/legacy-cli/e2e/tests/generate/generate-error.ts deleted file mode 100644 index 08d85cc02b43..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/generate-error.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {ng} from '../../utils/process'; -import {deleteFile} from '../../utils/fs'; -import {expectToFail} from '../../utils/utils'; - -export default function() { - return deleteFile('angular.json') - .then(() => expectToFail(() => ng('generate', 'class', 'hello'))); -} diff --git a/tests/legacy-cli/e2e/tests/generate/generate-name-check.ts b/tests/legacy-cli/e2e/tests/generate/generate-name-check.ts deleted file mode 100644 index a42a5cd6ca9a..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/generate-name-check.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; -import {updateJsonFile} from '../../utils/project'; - - -export default function() { - const compDir = join('src', 'app', 'test-component'); - - return Promise.resolve() - .then(() => updateJsonFile('package.json', configJson => { - delete configJson.name; - return configJson; - })) - .then(() => ng('generate', 'component', 'test-component')) - .then(() => expectFileToExist(compDir)) - .then(() => expectFileToExist(join(compDir, 'test-component.component.ts'))) - .then(() => expectFileToExist(join(compDir, 'test-component.component.spec.ts'))) - .then(() => expectFileToExist(join(compDir, 'test-component.component.html'))) - .then(() => expectFileToExist(join(compDir, 'test-component.component.css'))) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/generate-name-error.ts b/tests/legacy-cli/e2e/tests/generate/generate-name-error.ts deleted file mode 100644 index 2fb0583ca514..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/generate-name-error.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {ng} from '../../utils/process'; -import {expectToFail} from '../../utils/utils'; - - -export default function() { - return Promise.resolve() - .then(() => expectToFail(() => - ng('generate', 'component', '1my-component'))); -} diff --git a/tests/legacy-cli/e2e/tests/generate/guard/guard-basic.ts b/tests/legacy-cli/e2e/tests/generate/guard/guard-basic.ts deleted file mode 100644 index a30cfec2f310..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/guard/guard-basic.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist} from '../../../utils/fs'; - - -export default function() { - // Does not create a sub directory. - const guardDir = join('src', 'app'); - - return ng('generate', 'guard', 'test-guard') - .then(() => expectFileToExist(guardDir)) - .then(() => expectFileToExist(join(guardDir, 'test-guard.guard.ts'))) - .then(() => expectFileToExist(join(guardDir, 'test-guard.guard.spec.ts'))); - - - // Try to run the unit tests. - // TODO: Enable once schematic is updated for rxjs 6 - // .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/help-output.ts b/tests/legacy-cli/e2e/tests/generate/help-output.ts deleted file mode 100644 index 0b2f8607ca6c..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/help-output.ts +++ /dev/null @@ -1,99 +0,0 @@ -import {join} from 'path'; -import {ng, ProcessOutput} from '../../utils/process'; -import {writeMultipleFiles, createDir} from '../../utils/fs'; -import { updateJsonFile } from '../../utils/project'; - - -export default function() { - // setup temp collection - const genRoot = join('node_modules/fake-schematics/'); - - return Promise.resolve() - .then(() => createDir(genRoot)) - .then(() => writeMultipleFiles({ - [join(genRoot, 'package.json')]: ` - { - "schematics": "./collection.json" - }`, - [join(genRoot, 'collection.json')]: ` - { - "schematics": { - "fake": { - "factory": "./fake", - "description": "Fake schematic", - "schema": "./fake-schema.json" - } - } - }`, - [join(genRoot, 'fake-schema.json')]: ` - { - "id": "FakeSchema", - "title": "Fake Schema", - "type": "object", - "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" - } - }, - "required": [] - }`, - [join(genRoot, 'fake.js')]: ` - function def(options) { - return (host, context) => { - return host; - }; - } - exports.default = def; - `}, - )) - .then(() => ng('generate', 'fake-schematics:fake', '--help')) - .then(({stdout}) => { - if (!/ng generate fake-schematics:fake \[options\]/.test(stdout)) { - throw new Error('Help signature is wrong (1).'); - } - if (!/opt-a[\s\S]*opt-b[\s\S]*opt-c/.test(stdout)) { - throw new Error('Help signature options are incorrect.'); - } - }) - // set up default collection. - .then(() => updateJsonFile('angular.json', json => { - json.cli = json.cli || {} as any; - json.cli.defaultCollection = 'fake-schematics'; - })) - .then(() => ng('generate', 'fake', '--help')) - // verify same output - .then(({stdout}) => { - if (!/ng generate fake \[options\]/.test(stdout)) { - throw new Error('Help signature is wrong (2).'); - } - if (!/opt-a[\s\S]*opt-b[\s\S]*opt-c/.test(stdout)) { - throw new Error('Help signature options are incorrect.'); - } - }); - -} diff --git a/tests/legacy-cli/e2e/tests/generate/interface.ts b/tests/legacy-cli/e2e/tests/generate/interface.ts deleted file mode 100644 index e74f2570f4f1..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/interface.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../utils/process'; -import {expectFileToExist} from '../../utils/fs'; - - -export default function() { - const interfaceDir = join('src', 'app'); - - return ng('generate', 'interface', 'test-interface', 'model') - .then(() => expectFileToExist(interfaceDir)) - .then(() => expectFileToExist(join(interfaceDir, 'test-interface.model.ts'))) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/library/library-basic.ts b/tests/legacy-cli/e2e/tests/generate/library/library-basic.ts deleted file mode 100644 index 8e5f10848de7..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/library/library-basic.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ng } from '../../../utils/process'; -import { expectFileToMatch } from '../../../utils/fs'; -import { useCIChrome } from '../../../utils/project'; - -export default function () { - return ng('generate', 'library', 'my-lib') - .then(() => expectFileToMatch('angular.json', /\"my-lib\":/)) - .then(() => useCIChrome('projects/my-lib')) - .then(() => ng('build', 'my-lib')) - .then(() => ng('test', 'my-lib', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/library/library-consumption.ts b/tests/legacy-cli/e2e/tests/generate/library/library-consumption.ts deleted file mode 100644 index 075ba91da20c..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/library/library-consumption.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ng } from '../../../utils/process'; -import { writeFile } from '../../../utils/fs'; - -export default function () { - return ng('generate', 'library', 'my-lib') - .then(() => ng('build', 'my-lib')) - .then(() => writeFile('./src/app/app.module.ts', ` - import { BrowserModule } from '@angular/platform-browser'; - import { NgModule } from '@angular/core'; - import { MyLibModule } from 'my-lib'; - - import { AppComponent } from './app.component'; - - @NgModule({ - declarations: [ - AppComponent - ], - imports: [ - BrowserModule, - MyLibModule, - ], - providers: [], - bootstrap: [AppComponent] - }) - export class AppModule { } - `)) - .then(() => writeFile('./src/app/app.component.ts', ` - import { Component } from '@angular/core'; - import { MyLibService } from 'my-lib'; - - @Component({ - selector: 'app-root', - template: '' - }) - export class AppComponent { - title = 'app'; - - constructor(myLibService: MyLibService) { - console.log(myLibService); - } - } - `)) - // Check that the build succeeds both with named project, unnammed (should build app), and prod. - .then(() => ng('build', 'test-project')) - .then(() => ng('build')) - .then(() => ng('build', '--prod')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/lint-fix.ts b/tests/legacy-cli/e2e/tests/generate/lint-fix.ts deleted file mode 100644 index 10394ca45561..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/lint-fix.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ng } from '../../utils/process'; -import { writeFile } from '../../utils/fs'; -import { expectToFail } from '../../utils/utils'; - -export default function () { - const nestedConfigContent = ` - { - "rules": { - "quotemark": [ - true, - "double", - "avoid-escape" - ] - } - }`; - - return Promise.resolve() - - // setup a double-quote tslint config - .then(() => writeFile('src/app/tslint.json', nestedConfigContent)) - - // Generate a fixed new component but don't fix rest of app - .then(() => ng('generate', 'component', 'test-component1', '--lint-fix')) - .then(() => expectToFail(() => ng('lint'))) - - // Fix rest of app and generate new component - .then(() => ng('lint', '--fix')) - .then(() => ng('generate', 'component', 'test-component2', '--lint-fix')) - .then(() => ng('lint')) - - // Enable default option and generate all other module related blueprints - .then(() => ng('config', 'schematics.@schematics/angular.directive.lintFix', 'true')) - .then(() => ng('config', 'schematics.@schematics/angular.service.lintFix', 'true')) - .then(() => ng('config', 'schematics.@schematics/angular.pipe.lintFix', 'true')) - .then(() => ng('config', 'schematics.@schematics/angular.guard.lintFix', 'true')) - .then(() => ng('generate', 'directive', 'test-directive')) - .then(() => ng('generate', 'service', 'test-service')) - .then(() => ng('generate', 'pipe', 'test-pipe')) - .then(() => ng('generate', 'guard', 'test-guard')) - .then(() => ng('lint')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/module/module-basic.ts b/tests/legacy-cli/e2e/tests/generate/module/module-basic.ts deleted file mode 100644 index b78c3c22a112..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/module/module-basic.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist, expectFileToMatch} from '../../../utils/fs'; -import {expectToFail} from '../../../utils/utils'; - - -export default function() { - const moduleDir = join('src', 'app', 'test'); - - return ng('generate', 'module', 'test') - .then(() => expectFileToExist(moduleDir)) - .then(() => expectFileToExist(join(moduleDir, 'test.module.ts'))) - .then(() => expectToFail(() => expectFileToExist(join(moduleDir, 'test-routing.module.ts')))) - .then(() => expectToFail(() => expectFileToExist(join(moduleDir, 'test.spec.ts')))) - .then(() => expectFileToMatch(join(moduleDir, 'test.module.ts'), 'TestModule')) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/module/module-import.ts b/tests/legacy-cli/e2e/tests/generate/module/module-import.ts deleted file mode 100644 index d2b773da24c8..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/module/module-import.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { join } from 'path'; -import { ng } from '../../../utils/process'; -import { expectFileToMatch } from '../../../utils/fs'; - -// tslint:disable:max-line-length -export default function () { - const root = process.cwd(); - const modulePath = join(root, 'src', 'app', 'app.module.ts'); - const subModulePath = join('src', 'app', 'sub', 'sub.module.ts'); - const deepSubModulePath = join('src', 'app', 'sub', 'deep', 'deep.module.ts'); - - return Promise.resolve() - .then(() => ng('generate', 'module', 'sub')) - .then(() => ng('generate', 'module', 'sub/deep')) - - .then(() => ng('generate', 'module', 'test1', '--module', 'app.module.ts')) - .then(() => expectFileToMatch(modulePath, - /import { Test1Module } from '.\/test1\/test1.module'/)) - .then(() => expectFileToMatch(modulePath, /imports: \[(.|\s)*Test1Module(.|\s)*\]/m)) - - .then(() => ng('generate', 'module', 'test2', '--module', 'app.module')) - .then(() => expectFileToMatch(modulePath, - /import { Test2Module } from '.\/test2\/test2.module'/)) - .then(() => expectFileToMatch(modulePath, /imports: \[(.|\s)*Test2Module(.|\s)*\]/m)) - - .then(() => ng('generate', 'module', 'test3', '--module', 'app')) - .then(() => expectFileToMatch(modulePath, - /import { Test3Module } from '.\/test3\/test3.module'/)) - .then(() => expectFileToMatch(modulePath, /imports: \[(.|\s)*Test3Module(.|\s)*\]/m)) - - .then(() => ng('generate', 'module', 'test4', '--routing', '--module', 'app')) - .then(() => expectFileToMatch(modulePath, /imports: \[(.|\s)*Test4Module(.|\s)*\]/m)) - .then(() => expectFileToMatch(join('src', 'app', 'test4', 'test4.module.ts'), - /import { Test4RoutingModule } from '.\/test4-routing.module'/)) - .then(() => expectFileToMatch(join('src', 'app', 'test4', 'test4.module.ts'), - /imports: \[(.|\s)*Test4RoutingModule(.|\s)*\]/m)) - - .then(() => ng('generate', 'module', 'test5', '--module', 'sub')) - .then(() => expectFileToMatch(subModulePath, - /import { Test5Module } from '..\/test5\/test5.module'/)) - .then(() => expectFileToMatch(subModulePath, /imports: \[(.|\s)*Test5Module(.|\s)*\]/m)) - - .then(() => ng('generate', 'module', 'test6', '--module', join('sub', 'deep')) - .then(() => expectFileToMatch(deepSubModulePath, - /import { Test6Module } from '..\/..\/test6\/test6.module'/)) - .then(() => expectFileToMatch(deepSubModulePath, /imports: \[(.|\s)*Test6Module(.|\s)*\]/m))); - - // E2E_DISABLE: temporarily disable pending investigation - // .then(() => process.chdir(join(root, 'src', 'app'))) - // .then(() => ng('generate', 'module', 'test7', '--module', 'app.module.ts')) - // .then(() => process.chdir('..')) - // .then(() => expectFileToMatch(modulePath, - // /import { Test7Module } from '.\/test7\/test7.module'/)) - // .then(() => expectFileToMatch(modulePath, /imports: \[(.|\s)*Test7Module(.|\s)*\]/m)); -} diff --git a/tests/legacy-cli/e2e/tests/generate/module/module-routing-child-folder.ts b/tests/legacy-cli/e2e/tests/generate/module/module-routing-child-folder.ts deleted file mode 100644 index 240588a8967d..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/module/module-routing-child-folder.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { join } from 'path'; -import { ng } from '../../../utils/process'; -import { expectFileToExist } from '../../../utils/fs'; -import { expectToFail } from '../../../utils/utils'; - - -export default function () { - const root = process.cwd(); - const testPath = join(root, 'src', 'app'); - - process.chdir(testPath); - - return Promise.resolve() - .then(() => - ng('generate', 'module', 'sub-dir/child', '--routing') - .then(() => expectFileToExist(join(testPath, 'sub-dir/child'))) - .then(() => expectFileToExist(join(testPath, 'sub-dir/child', 'child.module.ts'))) - .then(() => expectFileToExist(join(testPath, 'sub-dir/child', 'child-routing.module.ts'))) - .then(() => expectToFail(() => - expectFileToExist(join(testPath, 'sub-dir/child', 'child.spec.ts')) - )) - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')) - ); -} diff --git a/tests/legacy-cli/e2e/tests/generate/module/module-routing.ts b/tests/legacy-cli/e2e/tests/generate/module/module-routing.ts deleted file mode 100644 index cf6208b0a84c..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/module/module-routing.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist} from '../../../utils/fs'; -import {expectToFail} from '../../../utils/utils'; - - -export default function() { - const moduleDir = join('src', 'app', 'test'); - - return ng('generate', 'module', 'test', '--routing') - .then(() => expectFileToExist(moduleDir)) - .then(() => expectFileToExist(join(moduleDir, 'test.module.ts'))) - .then(() => expectFileToExist(join(moduleDir, 'test-routing.module.ts'))) - .then(() => expectToFail(() => expectFileToExist(join(moduleDir, 'test.spec.ts')))) - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/pipe/pipe-basic.ts b/tests/legacy-cli/e2e/tests/generate/pipe/pipe-basic.ts deleted file mode 100644 index 0480c13cd2ff..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/pipe/pipe-basic.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; - -import {expectFileToExist} from '../../../utils/fs'; - -export default function() { - // Create the pipe in the same directory. - const pipeDir = join('src', 'app'); - - return ng('generate', 'pipe', 'test-pipe') - .then(() => expectFileToExist(pipeDir)) - .then(() => expectFileToExist(join(pipeDir, 'test-pipe.pipe.ts'))) - .then(() => expectFileToExist(join(pipeDir, 'test-pipe.pipe.spec.ts'))) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/pipe/pipe-in-existing-module-dir.ts b/tests/legacy-cli/e2e/tests/generate/pipe/pipe-in-existing-module-dir.ts deleted file mode 100644 index 2055643a1b5b..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/pipe/pipe-in-existing-module-dir.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { join } from 'path'; -import { ng } from '../../../utils/process'; -import { expectFileToMatch } from '../../../utils/fs'; - -export default function () { - const modulePath = join('src', 'app', 'foo', 'foo.module.ts'); - - return Promise.resolve() - .then(() => ng('generate', 'module', 'foo')) - .then(() => ng('generate', 'pipe', 'foo', '--no-flat')) - .then(() => expectFileToMatch(modulePath, /import { FooPipe } from '.\/foo.pipe'/)); -} diff --git a/tests/legacy-cli/e2e/tests/generate/pipe/pipe-module-export.ts b/tests/legacy-cli/e2e/tests/generate/pipe/pipe-module-export.ts deleted file mode 100644 index 570f7b339bcb..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/pipe/pipe-module-export.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; - - -export default function() { - const modulePath = join('src', 'app', 'app.module.ts'); - - return ng('generate', 'pipe', 'test-pipe', '--export') - .then(() => expectFileToMatch(modulePath, 'exports: [TestPipePipe]')) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/pipe/pipe-module-fail.ts b/tests/legacy-cli/e2e/tests/generate/pipe/pipe-module-fail.ts deleted file mode 100644 index e18d5e73d038..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/pipe/pipe-module-fail.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {ng} from '../../../utils/process'; -import {expectToFail} from '../../../utils/utils'; - - -export default function() { - return Promise.resolve() - .then(() => expectToFail(() => - ng('generate', 'pipe', 'test-pipe', '--module', 'app.moduleXXX.ts'))); -} diff --git a/tests/legacy-cli/e2e/tests/generate/pipe/pipe-module.ts b/tests/legacy-cli/e2e/tests/generate/pipe/pipe-module.ts deleted file mode 100644 index e5986f01ec57..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/pipe/pipe-module.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToMatch} from '../../../utils/fs'; - - -export default function() { - const modulePath = join('src', 'app', 'app.module.ts'); - - return ng('generate', 'pipe', 'test-pipe', '--module', 'app.module.ts') - .then(() => expectFileToMatch(modulePath, - /import { TestPipePipe } from '.\/test-pipe.pipe'/)) - - .then(() => process.chdir(join('src', 'app'))) - .then(() => ng('generate', 'pipe', 'test-pipe2', '--module', 'app.module.ts')) - .then(() => process.chdir('../..')) - .then(() => expectFileToMatch(modulePath, - /import { TestPipe2Pipe } from '.\/test-pipe2.pipe'/)) - - // Try to run the unit tests. - .then(() => ng('build')); -} diff --git a/tests/legacy-cli/e2e/tests/generate/service/service-basic.ts b/tests/legacy-cli/e2e/tests/generate/service/service-basic.ts deleted file mode 100644 index 3ebd58fc977c..000000000000 --- a/tests/legacy-cli/e2e/tests/generate/service/service-basic.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../../utils/process'; -import {expectFileToExist} from '../../../utils/fs'; - - -export default function() { - // Does not create a sub directory. - const serviceDir = join('src', 'app'); - - return ng('generate', 'service', 'test-service') - .then(() => expectFileToExist(serviceDir)) - .then(() => expectFileToExist(join(serviceDir, 'test-service.service.ts'))) - .then(() => expectFileToExist(join(serviceDir, 'test-service.service.spec.ts'))) - - // Try to run the unit tests. - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/i18n/build-locale.ts b/tests/legacy-cli/e2e/tests/i18n/build-locale.ts deleted file mode 100644 index ce537cd3e25a..000000000000 --- a/tests/legacy-cli/e2e/tests/i18n/build-locale.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ng } from '../../utils/process'; -import { expectFileToMatch, rimraf } from '../../utils/fs'; -import { getGlobalVariable } from '../../utils/env'; - - -export default function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - // Skip this test in Angular 2/4. - if (getGlobalVariable('argv').ng2 || getGlobalVariable('argv').ng4) { - return Promise.resolve(); - } - - // These tests should be moved to the default when we use ng5 in new projects. - return Promise.resolve() - // tests for register_locale_data transformer - .then(() => ng('build', '--aot', '--i18n-locale=fr')) - .then(() => expectFileToMatch('dist/test-project/main.js', /registerLocaleData/)) - .then(() => expectFileToMatch('dist/test-project/main.js', /angular_common_locales_fr/)) - .then(() => rimraf('dist')) - .then(() => ng('build', '--aot', '--i18n-locale=fr_FR')) - .then(() => expectFileToMatch('dist/test-project/main.js', /registerLocaleData/)) - .then(() => expectFileToMatch('dist/test-project/main.js', /angular_common_locales_fr/)) - .then(() => rimraf('dist')) -} diff --git a/tests/legacy-cli/e2e/tests/i18n/extract-default.ts b/tests/legacy-cli/e2e/tests/i18n/extract-default.ts deleted file mode 100644 index af1c0330a396..000000000000 --- a/tests/legacy-cli/e2e/tests/i18n/extract-default.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../utils/process'; -import { - expectFileToExist, writeFile, - expectFileToMatch -} from '../../utils/fs'; - - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return ng('generate', 'component', 'i18n-test') - .then(() => writeFile( - join('src/app/i18n-test', 'i18n-test.component.html'), - '

Hello world

')) - .then(() => ng('xi18n')) - .then(() => expectFileToExist(join('src', 'messages.xlf'))) - .then(() => expectFileToMatch(join('src', 'messages.xlf'), /Hello world/)); -} diff --git a/tests/legacy-cli/e2e/tests/i18n/extract-errors.ts b/tests/legacy-cli/e2e/tests/i18n/extract-errors.ts deleted file mode 100644 index a000ba27e404..000000000000 --- a/tests/legacy-cli/e2e/tests/i18n/extract-errors.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ng } from '../../utils/process'; -import { writeFile } from '../../utils/fs'; -import { expectToFail } from '../../utils/utils'; -import { join } from 'path'; - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return ng('generate', 'component', 'i18n-test') - .then(() => writeFile( - join('src/app/i18n-test', 'i18n-test.component.html'), - '

Hello world inner

')) - .then(() => expectToFail(() => ng('xi18n'))) - .then(({ message }) => { - if (!message.includes('Could not mark an element as' + - ' translatable inside a translatable section')) { - throw new Error(`Expected i18n extraction error, got this instead:\n${message}`); - } - }); -} diff --git a/tests/legacy-cli/e2e/tests/i18n/extract-locale.ts b/tests/legacy-cli/e2e/tests/i18n/extract-locale.ts deleted file mode 100644 index de09da9a8e1e..000000000000 --- a/tests/legacy-cli/e2e/tests/i18n/extract-locale.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { join } from 'path'; -import { ng } from '../../utils/process'; -import { writeFile, expectFileToMatch } from '../../utils/fs'; - - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return ng('generate', 'component', 'i18n-test') - .then(() => writeFile( - join('src/app/i18n-test', 'i18n-test.component.html'), - '

Hello world

')) - .then(() => ng('xi18n', '--i18n-locale', 'fr')) - .then((output) => { - if (!output.stderr.match(/starting from Angular v4/)) { - return expectFileToMatch('src/messages.xlf', 'source-language="fr"'); - } - }); -} diff --git a/tests/legacy-cli/e2e/tests/i18n/extract-outfile.ts b/tests/legacy-cli/e2e/tests/i18n/extract-outfile.ts deleted file mode 100644 index b431e953ef2e..000000000000 --- a/tests/legacy-cli/e2e/tests/i18n/extract-outfile.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../utils/process'; -import { writeFile, expectFileToMatch } from '../../utils/fs'; - - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return ng('generate', 'component', 'i18n-test') - .then(() => writeFile( - join('src/app/i18n-test', 'i18n-test.component.html'), - '

Hello world

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

Hello world

')) -// .then(() => ng('xi18n', '--output-path', 'src/locale')) -// .then(() => expectFileToExist(join('src', 'locale', 'messages.xlf'))) -// .then(() => expectFileToMatch(join('src', 'locale', 'messages.xlf'), /Hello world/)); -// } diff --git a/tests/legacy-cli/e2e/tests/i18n/extract-xmb.ts b/tests/legacy-cli/e2e/tests/i18n/extract-xmb.ts deleted file mode 100644 index f03a37fcd606..000000000000 --- a/tests/legacy-cli/e2e/tests/i18n/extract-xmb.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {join} from 'path'; -import {ng} from '../../utils/process'; -import { - expectFileToExist, writeFile, - expectFileToMatch -} from '../../utils/fs'; - - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return ng('generate', 'component', 'i18n-test') - .then(() => writeFile( - join('src/app/i18n-test', 'i18n-test.component.html'), - '

Hello world

')) - .then(() => ng('xi18n', '--i18n-format', 'xmb')) - .then(() => expectFileToExist('src/messages.xmb')) - .then(() => expectFileToMatch('src/messages.xmb', /Hello world/)); -} diff --git a/tests/legacy-cli/e2e/tests/lint/lint-no-config-section.ts b/tests/legacy-cli/e2e/tests/lint/lint-no-config-section.ts deleted file mode 100644 index 473a6fbb10f9..000000000000 --- a/tests/legacy-cli/e2e/tests/lint/lint-no-config-section.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ng } from '../../utils/process'; -import { oneLine } from 'common-tags'; - -export default function () { - // TODO(architect): Figure out how this test should look like post devkit/build-angular. - return; - - return Promise.resolve() - .then(() => ng('config', 'lint', '[]')) - .then(() => ng('lint', 'app')) - .then(({ stdout }) => { - if (!stdout.match(/No lint configuration\(s\) found\./)) { - throw new Error(oneLine` - Expected to match "No lint configuration(s) found." - in ${stdout}. - `); - } - - return stdout; - }); -} diff --git a/tests/legacy-cli/e2e/tests/lint/lint-no-project.ts b/tests/legacy-cli/e2e/tests/lint/lint-no-project.ts deleted file mode 100644 index 6253087a997c..000000000000 --- a/tests/legacy-cli/e2e/tests/lint/lint-no-project.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ng } from '../../utils/process'; -import { writeFile } from '../../utils/fs'; -import { expectToFail } from '../../utils/utils'; -import { oneLine } from 'common-tags'; -import { updateJsonFile } from '../../utils/project'; - -export default function () { - // TODO(architect): Figure out how this test should look like post devkit/build-angular. - return; - - return Promise.resolve() - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.lint.options.tsConfig = undefined; - })) - .then(() => ng('lint', 'app', '--type-check')) - .then(({ stdout }) => { - if (!stdout.match(/A "project" must be specified to enable type checking./)) { - throw new Error(oneLine` - Expected to match "A "project" must be specified to enable type checking." - in ${stdout}. - `); - } - - return stdout; - }) - .then(() => ng('config', 'lint.0.files', '"**/baz.ts"')) - .then(() => writeFile('src/app/foo.ts', 'const foo = "";\n')) - .then(() => writeFile('src/app/baz.ts', 'const baz = \'\';\n')) - .then(() => ng('lint', 'app')) - .then(() => ng('config', 'lint.0.files', '"**/foo.ts"')) - .then(() => expectToFail(() => ng('lint', 'app'))); -} diff --git a/tests/legacy-cli/e2e/tests/lint/lint-with-exclude.ts b/tests/legacy-cli/e2e/tests/lint/lint-with-exclude.ts deleted file mode 100644 index 887bd7a06542..000000000000 --- a/tests/legacy-cli/e2e/tests/lint/lint-with-exclude.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ng } from '../../utils/process'; -import { writeFile } from '../../utils/fs'; -import { oneLine } from 'common-tags'; - -export default function () { - // TODO(architect): Figure out how this test should look like post devkit/build-angular. - return; - - const fileName = 'src/app/foo.ts'; - - return Promise.resolve() - .then(() => ng('config', 'lint.0.exclude', '"**/foo.ts"')) - .then(() => writeFile(fileName, 'const foo = "";\n')) - .then(() => ng('lint', 'app')) - .then(({ stdout }) => { - if (!stdout.match(/All files pass linting\./)) { - throw new Error(oneLine` - Expected to match "All files pass linting." - in ${stdout}. - `); - } - }); -} diff --git a/tests/legacy-cli/e2e/tests/lint/lint-with-fix.ts b/tests/legacy-cli/e2e/tests/lint/lint-with-fix.ts deleted file mode 100644 index 41bbdd6762dc..000000000000 --- a/tests/legacy-cli/e2e/tests/lint/lint-with-fix.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ng } from '../../utils/process'; -import { readFile, writeFile } from '../../utils/fs'; - -export default function () { - // TODO(architect): Figure out how this test should look like post devkit/build-angular. - return; - - const fileName = 'src/app/foo.ts'; - - return Promise.resolve() - .then(() => writeFile(fileName, 'const foo = "";\n')) - .then(() => ng('lint', 'app', '--fix', '--force')) - .then(() => readFile(fileName)) - .then(content => { - if (!content.match(/const foo = '';/)) { - throw new Error(`Expected to match "const foo = '';" in ${content}.`); - } - }); -} diff --git a/tests/legacy-cli/e2e/tests/lint/lint-with-force.ts b/tests/legacy-cli/e2e/tests/lint/lint-with-force.ts deleted file mode 100644 index 147941721a52..000000000000 --- a/tests/legacy-cli/e2e/tests/lint/lint-with-force.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ng } from '../../utils/process'; -import { writeFile } from '../../utils/fs'; -import { oneLine } from 'common-tags'; - -export default function () { - // TODO(architect): Figure out how this test should look like post devkit/build-angular. - return; - - const fileName = 'src/app/foo.ts'; - - return Promise.resolve() - .then(() => writeFile(fileName, 'const foo = "";\n')) - .then(() => ng('lint', 'app', '--force')) - .then(({ stdout }) => { - if (!stdout.match(/" should be '/)) { - throw new Error(`Expected to match "" should be '" in ${stdout}.`); - } - - return stdout; - }) - .then((output) => { - if (!output.match(/Lint errors found in the listed files\./)) { - throw new Error(oneLine` - Expected to match "Lint errors found in the listed files." - in ${output}. - `); - } - }); -} diff --git a/tests/legacy-cli/e2e/tests/lint/lint-with-format-by-aliases.ts b/tests/legacy-cli/e2e/tests/lint/lint-with-format-by-aliases.ts deleted file mode 100644 index 8c1d983e9c18..000000000000 --- a/tests/legacy-cli/e2e/tests/lint/lint-with-format-by-aliases.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ng } from '../../utils/process'; -import { writeFile } from '../../utils/fs'; -import { oneLine } from 'common-tags'; - -export default function () { - // TODO(architect): Figure out how this test should look like post devkit/build-angular. - return; - - const fileName = 'src/app/foo.ts'; - - return Promise.resolve() - .then(() => writeFile(fileName, 'const foo = "";\n')) - .then(() => ng('lint', 'app', '-t=stylish', '--force')) - .then(({ stdout }) => { - if (!stdout.match(/1:13 quotemark " should be '/)) { - throw new Error(oneLine` - Expected to match "1:13 quotemark " should be '" - in ${stdout}. - `); - } - }); -} diff --git a/tests/legacy-cli/e2e/tests/lint/lint-with-format.ts b/tests/legacy-cli/e2e/tests/lint/lint-with-format.ts deleted file mode 100644 index 5376986eb08c..000000000000 --- a/tests/legacy-cli/e2e/tests/lint/lint-with-format.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ng } from '../../utils/process'; -import { writeFile } from '../../utils/fs'; -import { oneLine } from 'common-tags'; - -export default function () { - // TODO(architect): Figure out how this test should look like post devkit/build-angular. - return; - - const fileName = 'src/app/foo.ts'; - - return Promise.resolve() - .then(() => writeFile(fileName, 'const foo = "";\n')) - .then(() => ng('lint', 'app', '--format=stylish', '--force')) - .then(({ stdout }) => { - if (!stdout.match(/1:13 quotemark " should be '/)) { - throw new Error(oneLine` - Expected to match "1:13 quotemark " should be '" - in ${stdout}. - `); - } - }); -} diff --git a/tests/legacy-cli/e2e/tests/lint/lint-with-nested-configs.ts b/tests/legacy-cli/e2e/tests/lint/lint-with-nested-configs.ts deleted file mode 100644 index 5ba81188b981..000000000000 --- a/tests/legacy-cli/e2e/tests/lint/lint-with-nested-configs.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createDir, writeFile } from '../../utils/fs'; -import { ng } from '../../utils/process'; -import { expectToFail } from '../../utils/utils'; - - -export default function () { - // TODO(architect): Figure out how this test should look like post devkit/build-angular. - return; - - const fileName = 'src/app/foo/foo.ts'; - const nestedConfigContent = ` - { - "rules": { - "quotemark": [ - true, - "double", - "avoid-escape" - ] - } - }`; - - return Promise.resolve() - .then(() => createDir('src/app/foo')) - .then(() => writeFile(fileName, 'const foo = \'\';\n')) - .then(() => writeFile('src/app/foo/tslint.json', nestedConfigContent)) - .then(() => expectToFail(() => ng('lint', 'app'))); -} diff --git a/tests/legacy-cli/e2e/tests/lint/lint-with-non-project.ts b/tests/legacy-cli/e2e/tests/lint/lint-with-non-project.ts deleted file mode 100644 index ebe4c207181a..000000000000 --- a/tests/legacy-cli/e2e/tests/lint/lint-with-non-project.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ng } from '../../utils/process'; -import { expectToFail } from '../../utils/utils'; - -export default function () { - // TODO(architect): Figure out how this test should look like post devkit/build-angular. - return; - - return Promise.resolve() - .then(() => ng('config', 'lint.0.files', '"src/app/**/*.ts"')) - .then(() => expectToFail(() => ng('lint', 'app'))); -} diff --git a/tests/legacy-cli/e2e/tests/lint/lint-with-type-check-fail.ts b/tests/legacy-cli/e2e/tests/lint/lint-with-type-check-fail.ts deleted file mode 100644 index 448c79822a3f..000000000000 --- a/tests/legacy-cli/e2e/tests/lint/lint-with-type-check-fail.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ng } from '../../utils/process'; -import { expectToFail } from '../../utils/utils'; -import { writeFile } from '../../utils/fs'; - - -export default function () { - // TODO(architect): Figure out how this test should look like post devkit/build-angular. - return; - - const fileName = 'src/app/foo.ts'; - const fileContents = ` -const ANIMATION_CSS_VALUE_REGEX = 'asda'; -const a = ["asda", 'asda', 'asdasd', "ASDASDAS"]; -const b = "asdasd"; -const c = { - a: "sadas", - b: { - v: "asdasda", - s: ["asda", "asdas", 10, true, "asda"], - } -}; - -function check(val: any, fxState: any) { - if (typeof val === "string" && val.indexOf(" ") < 0) { - var r = val.match(ANIMATION_CSS_VALUE_REGEX); - var num = parseFloat(r[1]); - - if (!isNaN(num)) { - fxState.num = num + ""; - } - fxState.unit = (r[0] !== r[2] ? r[2] : ""); - - } else if (typeof val === "number") { - fxState.num = val + ""; - } -} - - `; - - return Promise.resolve() - .then(() => writeFile(fileName, fileContents)) - .then(() => expectToFail(() => ng('lint', 'app', '--fix', '--type-check'))); -} diff --git a/tests/legacy-cli/e2e/tests/lint/lint-with-type-check.ts b/tests/legacy-cli/e2e/tests/lint/lint-with-type-check.ts deleted file mode 100644 index 1c41f38e6a1d..000000000000 --- a/tests/legacy-cli/e2e/tests/lint/lint-with-type-check.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ng } from '../../utils/process'; -import { writeFile } from '../../utils/fs'; - - -export default function () { - // TODO(architect): Figure out how this test should look like post devkit/build-angular. - return; - - const fileName = 'src/app/foo.ts'; - const fileContents = ` -const ANIMATION_CSS_VALUE_REGEX = 'asda'; -const a = ["asda", 'asda', 'asdasd', "ASDASDAS"]; -const b = "asdasd"; -const c = { - a: "sadas", - b: { - v: "asdasda", - s: ["asda", "asdas", 10, true, "asda"], - } -}; - -function check(val: any, fxState: any) { - if (typeof val === "string" && val.indexOf(" ") < 0) { - let r = val.match(ANIMATION_CSS_VALUE_REGEX); - let num = parseFloat(r[1]); - - if (!isNaN(num)) { - fxState.num = num + ""; - } - fxState.unit = (r[0] !== r[2] ? r[2] : ""); - - } else if (typeof val === "number") { - fxState.num = val + ""; - } -} - - `; - - return Promise.resolve() - .then(() => writeFile(fileName, fileContents)) - .then(() => ng('lint', 'app', '--fix')) - .then(() => ng('lint', 'app')) - .then(({ stdout }) => { - if (!stdout.match(/All files pass linting./)) { - throw new Error('All files pass linting.'); - } - }); -} diff --git a/tests/legacy-cli/e2e/tests/lint/lint.ts b/tests/legacy-cli/e2e/tests/lint/lint.ts deleted file mode 100644 index 49bd7aadae4b..000000000000 --- a/tests/legacy-cli/e2e/tests/lint/lint.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ng } from '../../utils/process'; -import { oneLine } from 'common-tags'; - -export default function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return ng('lint', 'test-project') - .then(({ stdout }) => { - if (!stdout.match(/All files pass linting\./)) { - throw new Error(oneLine` - Expected to match "All files pass linting." - in ${stdout}. - `); - } - }); -} diff --git a/tests/legacy-cli/e2e/tests/misc/assets.ts b/tests/legacy-cli/e2e/tests/misc/assets.ts deleted file mode 100644 index 181a07dac4a3..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/assets.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {writeFile, expectFileToExist, expectFileToMatch} from '../../utils/fs'; -import {ng} from '../../utils/process'; -import {expectToFail} from '../../utils/utils'; - - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return writeFile('src/assets/.file', '') - .then(() => writeFile('src/assets/test.abc', 'hello world')) - .then(() => ng('build')) - .then(() => expectFileToExist('dist/test-project/favicon.ico')) - .then(() => expectFileToExist('dist/test-project/assets/.file')) - .then(() => expectFileToMatch('dist/test-project/assets/test.abc', 'hello world')) - .then(() => expectToFail(() => expectFileToExist('dist/test-project/assets/.gitkeep'))); -} diff --git a/tests/legacy-cli/e2e/tests/misc/circular-dependency.ts b/tests/legacy-cli/e2e/tests/misc/circular-dependency.ts deleted file mode 100644 index 0d18cf3e6dad..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/circular-dependency.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { prependToFile } from '../../utils/fs'; -import { ng } from '../../utils/process'; - - -export default async function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - await prependToFile('src/app/app.component.ts', - `import { AppModule } from './app.module'; console.log(AppModule);`); - let output = await ng('build', '--show-circular-dependencies'); - if (!output.stdout.match(/WARNING in Circular dependency detected/)) { - throw new Error('Expected to have circular dependency warning in output.'); - } -} diff --git a/tests/legacy-cli/e2e/tests/misc/common-async.ts b/tests/legacy-cli/e2e/tests/misc/common-async.ts deleted file mode 100644 index 9c317e759694..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/common-async.ts +++ /dev/null @@ -1,78 +0,0 @@ -import {readdirSync} from 'fs'; -import {oneLine} from 'common-tags'; - -import {ng, silentNpm} from '../../utils/process'; -import {appendToFile, expectFileToExist, prependToFile, replaceInFile} from '../../utils/fs'; -import {expectToFail} from '../../utils/utils'; - - -export default function() { - // TODO(architect): The common chunk seems to have a different name in devkit/build-angular. - // Investigate, validate, then delete this test. - return; - - let oldNumberOfFiles = 0; - return Promise.resolve() - .then(() => ng('build')) - .then(() => oldNumberOfFiles = readdirSync('dist/test-project').length) - .then(() => ng('generate', 'module', 'lazyA', '--routing')) - .then(() => ng('generate', 'module', 'lazyB', '--routing')) - .then(() => prependToFile('src/app/app.module.ts', ` - import { RouterModule } from '@angular/router'; - `)) - .then(() => replaceInFile('src/app/app.module.ts', 'imports: [', `imports: [ - RouterModule.forRoot([{ path: "lazyA", loadChildren: "./lazy-a/lazy-a.module#LazyAModule" }]), - RouterModule.forRoot([{ path: "lazyB", loadChildren: "./lazy-b/lazy-b.module#LazyBModule" }]), - `)) - .then(() => ng('build')) - .then(() => readdirSync('dist').length) - .then(currentNumberOfDistFiles => { - if (oldNumberOfFiles >= currentNumberOfDistFiles) { - throw new Error('A bundle for the lazy module was not created.'); - } - oldNumberOfFiles = currentNumberOfDistFiles; - }) - .then(() => silentNpm('install', 'moment')) - .then(() => appendToFile('src/app/lazy-a/lazy-a.module.ts', ` - import * as moment from 'moment'; - console.log(moment); - `)) - .then(() => ng('build')) - .then(() => readdirSync('dist/test-project').length) - .then(currentNumberOfDistFiles => { - if (oldNumberOfFiles != currentNumberOfDistFiles) { - throw new Error('The build contains a different number of files.'); - } - }) - .then(() => appendToFile('src/app/lazy-b/lazy-b.module.ts', ` - import * as moment from 'moment'; - console.log(moment); - `)) - .then(() => ng('build')) - .then(() => expectFileToExist('dist/test-project/common.chunk.js')) - .then(() => readdirSync('dist/test-project').length) - .then(currentNumberOfDistFiles => { - if (oldNumberOfFiles >= currentNumberOfDistFiles) { - throw new Error(oneLine`The build contains the wrong number of files. - The test for 'dist/test-project/common.chunk.js' to exist should have failed.`); - } - oldNumberOfFiles = currentNumberOfDistFiles; - }) - .then(() => ng('build', '--no-common-chunk')) - .then(() => expectToFail(() => expectFileToExist('dist/test-project/common.chunk.js'))) - .then(() => readdirSync('dist/test-project').length) - .then(currentNumberOfDistFiles => { - if (oldNumberOfFiles <= currentNumberOfDistFiles) { - throw new Error(oneLine`The build contains the wrong number of files. - The test for 'dist/test-project/common.chunk.js' not to exist should have failed.`); - } - }) - // Check for AoT and lazy routes. - .then(() => ng('build', '--aot')) - .then(() => readdirSync('dist/test-project').length) - .then(currentNumberOfDistFiles => { - if (oldNumberOfFiles != currentNumberOfDistFiles) { - throw new Error('AoT build contains a different number of files.'); - } - }); -} diff --git a/tests/legacy-cli/e2e/tests/misc/coverage.ts b/tests/legacy-cli/e2e/tests/misc/coverage.ts deleted file mode 100644 index af42921fbf76..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/coverage.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {expectFileToExist, expectFileToMatch} from '../../utils/fs'; -import {updateJsonFile} from '../../utils/project'; -import {expectToFail} from '../../utils/utils'; -import {ng} from '../../utils/process'; - - -export default function () { - // TODO(architect): This test is broken in devkit/build-angular, istanbul and - // istanbul-instrumenter-loader are missing from the dependencies. - return; - - return ng('test', '--watch=false', '--code-coverage') - .then(output => expect(output.stdout).toContain('Coverage summary')) - .then(() => expectFileToExist('coverage/src/app')) - .then(() => expectFileToExist('coverage/lcov.info')) - // Verify code coverage exclude work - .then(() => expectFileToMatch('coverage/lcov.info', 'polyfills.ts')) - .then(() => expectFileToMatch('coverage/lcov.info', 'test.ts')) - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.test.options.codeCoverageExclude = [ - 'src/polyfills.ts', - '**/test.ts', - ]; - })) - .then(() => ng('test', '--watch=false', '--code-coverage')) - .then(() => expectToFail(() => expectFileToMatch('coverage/lcov.info', 'polyfills.ts'))) - .then(() => expectToFail(() => expectFileToMatch('coverage/lcov.info', 'test.ts'))); -} diff --git a/tests/legacy-cli/e2e/tests/misc/different-file-format.ts b/tests/legacy-cli/e2e/tests/misc/different-file-format.ts deleted file mode 100644 index 47ffe11c0943..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/different-file-format.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {ng} from '../../utils/process'; -import * as fs from '../../utils/fs'; - - -const options = { - encoding: 'utf8' -}; - - -export default function() { - return Promise.resolve() - .then(() => fs.prependToFile('./src/tsconfig.app.json', '\ufeff', options)) - .then(() => fs.prependToFile('angular.json', '\ufeff', options)) - .then(() => ng('build', '--environment=dev')); -} diff --git a/tests/legacy-cli/e2e/tests/misc/fallback.ts b/tests/legacy-cli/e2e/tests/misc/fallback.ts deleted file mode 100644 index 1c8d1ca56ea1..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/fallback.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { request } from '../../utils/http'; -import { killAllProcesses } from '../../utils/process'; -import { ngServe } from '../../utils/project'; -import { updateJsonFile } from '../../utils/project'; -import { moveFile } from '../../utils/fs'; - - -export default function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - // should fallback to config.app[0].index (index.html by default) - return Promise.resolve() - .then(() => ngServe()) - .then(() => request('http://localhost:4200/')) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) - // should correctly fallback to a changed index - .then(() => moveFile('src/index.html', 'src/not-index.html')) - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.index = 'src/not-index.html'; - })) - .then(() => ngServe()) - .then(() => request('http://localhost:4200/')) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }); -} diff --git a/tests/legacy-cli/e2e/tests/misc/lazy-module.ts b/tests/legacy-cli/e2e/tests/misc/lazy-module.ts deleted file mode 100644 index cf73ee7b06e2..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/lazy-module.ts +++ /dev/null @@ -1,88 +0,0 @@ -import {readdirSync} from 'fs'; - -import {ng, silentNpm} from '../../utils/process'; -import {appendToFile, writeFile, prependToFile, replaceInFile} from '../../utils/fs'; - - -export default function() { - let oldNumberOfFiles = 0; - return Promise.resolve() - .then(() => ng('build')) - .then(() => oldNumberOfFiles = readdirSync('dist').length) - .then(() => ng('generate', 'module', 'lazy', '--routing')) - .then(() => ng('generate', 'module', 'too/lazy', '--routing')) - .then(() => prependToFile('src/app/app.module.ts', ` - import { RouterModule } from '@angular/router'; - `)) - .then(() => replaceInFile('src/app/app.module.ts', 'imports: [', `imports: [ - RouterModule.forRoot([{ path: "lazy", loadChildren: "src/app/lazy/lazy.module#LazyModule" }]), - RouterModule.forRoot([{ path: "lazy1", loadChildren: "./lazy/lazy.module#LazyModule" }]), - RouterModule.forRoot([{ path: "lazy2", loadChildren: "./too/lazy/lazy.module#LazyModule" }]), - `)) - .then(() => ng('build', '--named-chunks')) - .then(() => readdirSync('dist/test-project')) - .then((distFiles) => { - const currentNumberOfDistFiles = distFiles.length; - if (oldNumberOfFiles >= currentNumberOfDistFiles) { - throw new Error('A bundle for the lazy module was not created.'); - } - oldNumberOfFiles = currentNumberOfDistFiles; - - if (!distFiles.includes('lazy-lazy-module.js')) { - throw new Error('The lazy module chunk did not have a name.'); - } - if (!distFiles.includes('too-lazy-lazy-module.js')) { - throw new Error('The lazy module chunk did not use a unique name.'); - } - }) - // verify System.import still works - .then(() => writeFile('src/app/lazy-file.ts', '')) - .then(() => appendToFile('src/app/app.component.ts', ` - // verify other System.import still work - declare var System: any; - const lazyFile = 'file'; - System.import(/*webpackChunkName: '[request]'*/'./lazy-' + lazyFile); - `)) - .then(() => ng('build', '--named-chunks')) - .then(() => readdirSync('dist/test-project')) - .then((distFiles) => { - const currentNumberOfDistFiles = distFiles.length; - if (oldNumberOfFiles >= currentNumberOfDistFiles) { - throw new Error('A bundle for the lazy file was not created.'); - } - if (!distFiles.includes('lazy-file.js')) { - throw new Error('The lazy file chunk did not have a name.'); - } - oldNumberOfFiles = currentNumberOfDistFiles; - }) - // verify 'import *' syntax doesn't break lazy modules - .then(() => silentNpm('install', 'moment')) - .then(() => appendToFile('src/app/app.component.ts', ` - import * as moment from 'moment'; - console.log(moment); - `)) - .then(() => ng('build')) - .then(() => readdirSync('dist/test-project').length) - .then(currentNumberOfDistFiles => { - if (oldNumberOfFiles != currentNumberOfDistFiles) { - throw new Error('Bundles were not created after adding \'import *\'.'); - } - }) - .then(() => ng('build', '--no-named-chunks')) - .then(() => readdirSync('dist/test-project')) - .then((distFiles) => { - if (distFiles.includes('lazy-lazy-module.js') - || distFiles.includes('too-lazy-lazy-module.js') - ) { - throw new Error('Lazy chunks shouldn\'t have a name but did.'); - } - }) - // Check for AoT and lazy routes. - .then(() => ng('build', '--aot')) - .then(() => readdirSync('dist/test-project').length) - .then(currentNumberOfDistFiles => { - if (oldNumberOfFiles != currentNumberOfDistFiles) { - throw new Error('AoT build contains a different number of files.'); - } - }); -} diff --git a/tests/legacy-cli/e2e/tests/misc/live-reload.ts b/tests/legacy-cli/e2e/tests/misc/live-reload.ts deleted file mode 100644 index c70ae92ab091..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/live-reload.ts +++ /dev/null @@ -1,172 +0,0 @@ -export default function temporarilyDisabledTest() { return Promise.resolve(); } -// import * as os from 'os'; -// import * as _ from 'lodash'; -// import * as express from 'express'; -// import * as http from 'http'; - -// import {appendToFile, writeMultipleFiles, writeFile} from '../../utils/fs'; -// import { -// killAllProcesses, -// execAndWaitForOutputToMatch, -// waitForAnyProcessOutputToMatch -// } from '../../utils/process'; -// import { wait } from '../../utils/utils'; - - -// export default function () { -// const protractorGoodRegEx = /Jasmine started/; -// const webpackGoodRegEx = /: Compiled successfully./; - -// // Create an express api for the Angular app to call. -// const app = express(); -// const server = http.createServer(app); -// let liveReloadCount = 0; -// function resetApiVars() { -// liveReloadCount = 0; -// } - -// server.listen(0); -// app.set('port', server.address().port); - -// const firstLocalIp = _(os.networkInterfaces()) -// .values() -// .flatten() -// .filter({ family: 'IPv4', internal: false }) -// .map('address') -// .first(); -// const publicHost = `${firstLocalIp}:4200`; - -// const apiUrl = `http://localhost:${server.address().port}`; - -// // This endpoint will be pinged by the main app on each reload. -// app.get('/live-reload-count', _ => liveReloadCount++); - -// const proxyConfigFile = 'proxy.config.json'; -// const proxyConfig = { -// '/live-reload-count': { -// target: apiUrl -// } -// }; - -// return Promise.resolve() -// .then(_ => writeMultipleFiles({ -// 'src/app/app.module.ts': ` -// import { BrowserModule } from '@angular/platform-browser'; -// import { NgModule } from '@angular/core'; -// import { FormsModule } from '@angular/forms'; -// import { HttpModule } from '@angular/http'; -// import { AppComponent } from './app.component'; -// @NgModule({ -// declarations: [ -// AppComponent -// ], -// imports: [ -// BrowserModule, -// FormsModule, -// HttpModule -// ], -// providers: [], -// bootstrap: [AppComponent] -// }) -// export class AppModule { } -// `, -// // e2e test that just opens the page and waits, so that the app runs. -// './e2e/app.e2e-spec.ts': ` -// import { browser } from 'protractor'; - -// describe('master-project App', function() { -// it('should wait', _ => { -// browser.get('/'); -// browser.sleep(30000); -// }); -// }); -// `, -// // App that calls the express server once. -// './src/app/app.component.ts': ` -// import { Component } from '@angular/core'; -// import { Http } from '@angular/http'; - -// @Component({ -// selector: 'app-root', -// template: '

Live reload test

' -// }) -// export class AppComponent { -// constructor(private http: Http) { -// http.get('${apiUrl + '/live-reload-count'}').subscribe(res => null); -// } -// } -// ` -// })) -// .then(_ => execAndWaitForOutputToMatch( -// 'ng', -// ['e2e', '--watch', '--live-reload'], -// protractorGoodRegEx -// )) -// // Let app run. -// .then(_ => wait(2000)) -// .then(_ => appendToFile('src/main.ts', 'console.log(1);')) -// .then(_ => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 10000)) -// .then(_ => wait(2000)) -// .then(_ => { -// if (liveReloadCount != 2) { -// throw new Error( -// `Expected API to have been called 2 times but it was called ${liveReloadCount} times.` -// ); -// } -// }) -// .then(_ => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) -// .then(_ => resetApiVars()) -// // Serve with live reload off should call api only once. -// .then(_ => execAndWaitForOutputToMatch( -// 'ng', -// ['e2e', '--watch', '--no-live-reload'], -// protractorGoodRegEx -// )) -// .then(_ => wait(2000)) -// .then(_ => appendToFile('src/main.ts', 'console.log(1);')) -// .then(_ => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 10000)) -// .then(_ => wait(2000)) -// .then(_ => { -// if (liveReloadCount != 1) { -// throw new Error( -// `Expected API to have been called 1 time but it was called ${liveReloadCount} times.` -// ); -// } -// }) -// .then(_ => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) -// .then(_ => resetApiVars()) -// // Serve with live reload client set to api should call api. -// .then(() => writeFile(proxyConfigFile, JSON.stringify(proxyConfig, null, 2))) -// // Update the component to call the webserver -// .then(() => writeFile('./src/app/app.component.ts', -// ` -// import { Component } from '@angular/core'; -// import { Http } from '@angular/http'; -// @Component({ -// selector: 'app-root', -// template: '

Live reload test

' -// }) -// export class AppComponent { -// constructor(private http: Http) { -// http.get('http://${publicHost + '/live-reload-count'}').subscribe(res => null); -// } -// }`)) -// .then(_ => execAndWaitForOutputToMatch( -// 'ng', -// ['e2e', '--watch', '--host=0.0.0.0', '--port=4200', `--public-host=${publicHost}`, '--proxy', proxyConfigFile], -// protractorGoodRegEx -// )) -// .then(_ => wait(2000)) -// .then(_ => appendToFile('src/main.ts', 'console.log(1);')) -// .then(_ => waitForAnyProcessOutputToMatch(webpackGoodRegEx, 10000)) -// .then(_ => wait(2000)) -// .then(_ => { -// if (liveReloadCount != 2) { -// throw new Error( -// `Expected API to have been called 2 times but it was called ${liveReloadCount} times.` -// ); -// } -// }) -// .then(_ => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) -// .then(_ => server.close(), (err) => { server.close(); throw err; }); -// } diff --git a/tests/legacy-cli/e2e/tests/misc/loaders-resolution.ts b/tests/legacy-cli/e2e/tests/misc/loaders-resolution.ts deleted file mode 100644 index c3267d41a070..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/loaders-resolution.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createDir, moveFile } from '../../utils/fs'; -import { ng } from '../../utils/process'; - -export default async function () { - await createDir('node_modules/@angular-devkit/build-angular/node_modules'); - await moveFile( - 'node_modules/@ngtools', - 'node_modules/@angular-devkit/build-angular/node_modules/@ngtools' - ); - - await ng('build'); - - // Move it back. - await moveFile( - 'node_modules/@angular-devkit/build-angular/node_modules/@ngtools', - 'node_modules/@ngtools', - ); -} diff --git a/tests/legacy-cli/e2e/tests/misc/minimal-config.ts b/tests/legacy-cli/e2e/tests/misc/minimal-config.ts deleted file mode 100644 index a5ad29d11990..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/minimal-config.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { writeFile, writeMultipleFiles } from '../../utils/fs'; -import { ng } from '../../utils/process'; - - -export default function () { - // TODO(architect): Figure out what a minimal config is for architect apps. - return; - - return Promise.resolve() - .then(() => writeFile('angular.json', JSON.stringify({ - apps: [{ - root: 'src', - main: 'main.ts', - scripts: [ - '../node_modules/core-js/client/shim.min.js', - '../node_modules/zone.js/dist/zone.js' - ] - }], - e2e: { protractor: { config: './protractor.conf.js' } } - }))) - .then(() => ng('e2e', 'test-project-e2e')) - .then(() => writeMultipleFiles({ - './src/script.js': ` - document.querySelector('app-root').innerHTML = '

app works!

'; - `, - './e2e/app.e2e-spec.ts': ` - import { browser, element, by } from 'protractor'; - - describe('minimal project App', function() { - it('should display message saying app works', () => { - browser.ignoreSynchronization = true; - browser.get('/'); - let el = element(by.css('app-root h1')).getText(); - expect(el).toEqual('app works!'); - }); - }); - `, - 'angular.json': JSON.stringify({ - apps: [{ - root: 'src', - scripts: ['./script.js'] - }], - e2e: { protractor: { config: './protractor.conf.js' } } - }), - })) - .then(() => ng('e2e', 'test-project-e2e')); -} diff --git a/tests/legacy-cli/e2e/tests/misc/module-resolution.ts b/tests/legacy-cli/e2e/tests/misc/module-resolution.ts deleted file mode 100644 index 0396650258c7..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/module-resolution.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { appendToFile, createDir, moveFile, prependToFile } from '../../utils/fs'; -import { ng, silentNpm } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; -import { expectToFail } from '../../utils/utils'; - - -export default async function () { - await updateJsonFile('tsconfig.json', tsconfig => { - tsconfig.compilerOptions.paths = { - '*': ['./node_modules/*'], - }; - }); - await ng('build'); - - await createDir('xyz'); - await moveFile( - 'node_modules/@angular/common', - 'xyz/common', - ); - - await expectToFail(() => ng('build')); - - await updateJsonFile('tsconfig.json', tsconfig => { - tsconfig.compilerOptions.paths = { - '@angular/common': [ './xyz/common' ], - }; - }); - await ng('build'); - - await updateJsonFile('tsconfig.json', tsconfig => { - tsconfig.compilerOptions.paths = { - '*': ['./node_modules/*'], - '@angular/common': [ './xyz/common' ], - }; - }); - await ng('build'); - - await updateJsonFile('tsconfig.json', tsconfig => { - tsconfig.compilerOptions.paths = { - '@angular/common': [ './xyz/common' ], - '*': ['./node_modules/*'], - }; - }); - await ng('build'); - - await updateJsonFile('tsconfig.json', tsconfig => { - delete tsconfig.compilerOptions.paths; - }); - - await prependToFile('src/app/app.module.ts', 'import * as firebase from \'firebase\';'); - await appendToFile('src/app/app.module.ts', 'firebase.initializeApp({});'); - - await silentNpm('install', 'firebase@3.7.8'); - await ng('build', '--aot'); - await ng('test', '--watch=false'); - - await silentNpm('install', 'firebase@4.9.0'); - await ng('build', '--aot'); - await ng('test', '--watch=false'); - - await updateJsonFile('tsconfig.json', tsconfig => { - tsconfig.compilerOptions.paths = {}; - }); - await ng('build'); - - await updateJsonFile('tsconfig.json', tsconfig => { - tsconfig.compilerOptions.paths = { - '@app/*': ['*'], - '@lib/*/test': ['*/test'], - }; - }); - await ng('build'); - - await updateJsonFile('tsconfig.json', tsconfig => { - tsconfig.compilerOptions.paths = { - '@firebase/polyfill': ['./node_modules/@firebase/polyfill/index.ts'], - }; - }); - await expectToFail(() => ng('build')); - - await updateJsonFile('tsconfig.json', tsconfig => { - tsconfig.compilerOptions.paths = { - '@firebase/polyfill*': ['./node_modules/@firebase/polyfill/index.ts'], - }; - }); - await expectToFail(() => ng('build')); -} diff --git a/tests/legacy-cli/e2e/tests/misc/multiple-targets.ts b/tests/legacy-cli/e2e/tests/misc/multiple-targets.ts deleted file mode 100644 index 58c92dcfea7d..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/multiple-targets.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { expectFileToExist } from '../../utils/fs'; -import { ng } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; - -export default async function () { - await ng('generate', 'app', 'secondary-app'); - - await updateJsonFile('angular.json', workspaceJson => { - workspaceJson.defaultProject = undefined; - }); - - await ng('build', 'secondary-app'); - - expectFileToExist('dist/secondary-app/index.html'); - expectFileToExist('dist/secondary-app/main.js'); -} diff --git a/tests/legacy-cli/e2e/tests/misc/non-relative-module-resolution.ts b/tests/legacy-cli/e2e/tests/misc/non-relative-module-resolution.ts deleted file mode 100644 index 12c10f7d8ce1..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/non-relative-module-resolution.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { prependToFile, writeMultipleFiles } from '../../utils/fs'; -import { ng } from '../../utils/process'; - - -export default async function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - await writeMultipleFiles({ - './src/app/foo.ts': ` - export const foo = 'fooo'; - `, - './src/app/bar.ts': ` - import { foo } from './foo'; - - console.log(foo); - ` - }), - - await prependToFile('src/app/app.module.ts', `import './bar';\n`); - - await ng('build'); - await ng('build', '--aot'); -} diff --git a/tests/legacy-cli/e2e/tests/misc/npm-audit.ts b/tests/legacy-cli/e2e/tests/misc/npm-audit.ts deleted file mode 100644 index c96e17132e80..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/npm-audit.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { npm } from '../../utils/process'; - - -export default async function() { - try { - await npm('audit'); - } catch {} -} diff --git a/tests/legacy-cli/e2e/tests/misc/proxy-config.ts b/tests/legacy-cli/e2e/tests/misc/proxy-config.ts deleted file mode 100644 index ec5ce3d7101e..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/proxy-config.ts +++ /dev/null @@ -1,71 +0,0 @@ -import * as express from 'express'; -import * as http from 'http'; - -import {writeFile} from '../../utils/fs'; -import {request} from '../../utils/http'; -import {killAllProcesses, ng} from '../../utils/process'; -import {ngServe} from '../../utils/project'; -import {updateJsonFile} from '../../utils/project'; -import {expectToFail} from "../../utils/utils"; - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - // Create an express app that serves as a proxy. - const app = express(); - const server = http.createServer(app); - server.listen(0); - - app.set('port', server.address().port); - app.get('/api/test', function (req, res) { - res.send('TEST_API_RETURN'); - }); - - const backendHost = 'localhost'; - const backendPort = server.address().port; - const proxyServerUrl = `http://${backendHost}:${backendPort}`; - const proxyConfigFile = 'proxy.config.json'; - const proxyConfig = { - '/api/*': { - target: proxyServerUrl - } - }; - - return Promise.resolve() - .then(() => writeFile(proxyConfigFile, JSON.stringify(proxyConfig, null, 2))) - .then(() => ngServe('--proxy-config', proxyConfigFile)) - .then(() => request('http://localhost:4200/api/test')) - .then(body => { - if (!body.match(/TEST_API_RETURN/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) - - // .then(() => updateJsonFile('angular.json', configJson => { - // const app = configJson.defaults; - // app.serve = { - // proxyConfig: proxyConfigFile - // }; - // })) - // .then(() => ngServe()) - // .then(() => request('http://localhost:4200/api/test')) - // .then(body => { - // if (!body.match(/TEST_API_RETURN/)) { - // throw new Error('Response does not match expected value.'); - // } - // }) - // .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) - - .then(() => server.close(), (err) => { server.close(); throw err; }) - - // // A non-existing proxy file should error. - // .then(() => expectToFail(() => ng('serve', '--proxy-config', 'proxy.non-existent.json'))) - // .then(() => updateJsonFile('angular.json', configJson => { - // const app = configJson.defaults; - // app.serve = { - // proxyConfig: 'proxy.non-existent.json' - // }; - // })) - // .then(() => expectToFail(() => ng('serve'))); -} diff --git a/tests/legacy-cli/e2e/tests/misc/public-host.ts b/tests/legacy-cli/e2e/tests/misc/public-host.ts deleted file mode 100644 index faf63bd82316..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/public-host.ts +++ /dev/null @@ -1,63 +0,0 @@ -import * as os from 'os'; -import * as _ from 'lodash'; - -import { request } from '../../utils/http'; -import { killAllProcesses } from '../../utils/process'; -import { ngServe } from '../../utils/project'; - -export default function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - const firstLocalIp = _(os.networkInterfaces()) - .values() - .flatten() - .filter({ family: 'IPv4', internal: false }) - .map('address') - .first(); - const publicHost = `${firstLocalIp}:4200`; - const localAddress = `http://${publicHost}`; - - return Promise.resolve() - // Disabling this test. Webpack Dev Server does not check the hots anymore when binding to - // numeric IP addresses. - // .then(() => ngServe('--host=0.0.0.0')) - // .then(() => request(localAddress)) - // .then(body => { - // if (!body.match(/Invalid Host header/)) { - // throw new Error('Response does not match expected value.'); - // } - // }) - // .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) - .then(() => ngServe('--host=0.0.0.0', `--public-host=${publicHost}`)) - .then(() => request(localAddress)) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) - .then(() => ngServe('--host=0.0.0.0', `--disable-host-check`)) - .then(() => request(localAddress)) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) - .then(() => ngServe('--host=0.0.0.0', `--public-host=${localAddress}`)) - .then(() => request(localAddress)) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }) - .then(() => ngServe('--host=0.0.0.0', `--public-host=${firstLocalIp}`)) - .then(() => request(localAddress)) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }); -} diff --git a/tests/legacy-cli/e2e/tests/misc/ssl-default.ts b/tests/legacy-cli/e2e/tests/misc/ssl-default.ts deleted file mode 100644 index e3b8cd2cfe4e..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/ssl-default.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { request } from '../../utils/http'; -import { killAllProcesses } from '../../utils/process'; -import { ngServe } from '../../utils/project'; - - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return Promise.resolve() - .then(() => ngServe('--ssl', 'true')) - .then(() => request('https://localhost:4200/')) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }); -} diff --git a/tests/legacy-cli/e2e/tests/misc/ssl-with-cert.ts b/tests/legacy-cli/e2e/tests/misc/ssl-with-cert.ts deleted file mode 100644 index a905d17322bf..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/ssl-with-cert.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { request } from '../../utils/http'; -import { assetDir } from '../../utils/assets'; -import { killAllProcesses } from '../../utils/process'; -import { ngServe } from '../../utils/project'; - - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return Promise.resolve() - .then(() => ngServe( - '--ssl', 'true', - '--ssl-key', assetDir('ssl/server.key'), - '--ssl-cert', assetDir('ssl/server.crt') - )) - .then(() => request('https://localhost:4200/')) - .then(body => { - if (!body.match(/<\/app-root>/)) { - throw new Error('Response does not match expected value.'); - } - }) - .then(() => killAllProcesses(), (err) => { killAllProcesses(); throw err; }); - -} diff --git a/tests/legacy-cli/e2e/tests/misc/third-party-decorators.ts b/tests/legacy-cli/e2e/tests/misc/third-party-decorators.ts deleted file mode 100644 index 1886318a051c..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/third-party-decorators.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { writeMultipleFiles } from '../../utils/fs'; -import { ng, npm } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; - -export default function () { - return updateJsonFile('package.json', packageJson => { - // Install ngrx - packageJson['dependencies']['@ngrx/effects'] = '^6.1.0'; - packageJson['dependencies']['@ngrx/schematics'] = '^6.1.0'; - packageJson['dependencies']['@ngrx/store'] = '^6.1.0'; - packageJson['dependencies']['@ngrx/store-devtools'] = '^6.1.0'; - }) - .then(() => npm('install')) - // Create an app that uses ngrx decorators and has e2e tests. - .then(_ => writeMultipleFiles({ - './e2e/src/app.po.ts': ` - import { browser, by, element } from 'protractor'; - export class AppPage { - navigateTo() { return browser.get('/'); } - getIncrementButton() { return element(by.buttonText('Increment')); } - getDecrementButton() { return element(by.buttonText('Decrement')); } - getResetButton() { return element(by.buttonText('Reset Counter')); } - getCounter() { return element(by.xpath('/html/body/app-root/div/span')).getText(); } - } - `, - './e2e/src/app.e2e-spec.ts': ` - import { AppPage } from './app.po'; - - describe('workspace-project App', () => { - let page: AppPage; - - beforeEach(() => { - page = new AppPage(); - }); - - it('should operate counter', () => { - page.navigateTo(); - page.getIncrementButton().click(); - page.getIncrementButton().click(); - expect(page.getCounter()).toEqual('2'); - page.getDecrementButton().click(); - expect(page.getCounter()).toEqual('1'); - page.getResetButton().click(); - expect(page.getCounter()).toEqual('0'); - }); - }); - `, - './src/app/app.component.ts': ` - import { Component } from '@angular/core'; - import { Store, select } from '@ngrx/store'; - import { Observable } from 'rxjs'; - import { INCREMENT, DECREMENT, RESET } from './counter.reducer'; - - interface AppState { - count: number; - } - - @Component({ - selector: 'app-root', - template: \` - -
Current Count: {{ count$ | async }}
- - - - \`, - }) - export class AppComponent { - count$: Observable; - - constructor(private store: Store) { - this.count$ = store.pipe(select(state => state.count)); - } - - increment() { - this.store.dispatch({ type: INCREMENT }); - } - - decrement() { - this.store.dispatch({ type: DECREMENT }); - } - - reset() { - this.store.dispatch({ type: RESET }); - } - } - `, - './src/app/app.effects.ts': ` - import { Injectable } from '@angular/core'; - import { Actions, Effect } from '@ngrx/effects'; - import { filter, map, tap } from 'rxjs/operators'; - - @Injectable() - export class AppEffects { - - @Effect() - mapper$ = this.actions$.pipe(map(() => ({ type: 'ANOTHER'})), filter(() => false)); - - @Effect({ dispatch: false }) - logger$ = this.actions$.pipe(tap(console.log)); - - constructor(private actions$: Actions) {} - } - `, - './src/app/app.module.ts': ` - import { BrowserModule } from '@angular/platform-browser'; - import { NgModule } from '@angular/core'; - - import { AppComponent } from './app.component'; - import { StoreModule } from '@ngrx/store'; - import { StoreDevtoolsModule } from '@ngrx/store-devtools'; - import { environment } from '../environments/environment'; - import { EffectsModule } from '@ngrx/effects'; - import { AppEffects } from './app.effects'; - import { counterReducer } from './counter.reducer'; - - @NgModule({ - declarations: [ - AppComponent - ], - imports: [ - BrowserModule, - StoreModule.forRoot({ count: counterReducer }), - !environment.production ? StoreDevtoolsModule.instrument() : [], - EffectsModule.forRoot([AppEffects]) - ], - providers: [], - bootstrap: [AppComponent] - }) - export class AppModule { } - `, - './src/app/counter.reducer.ts': ` - import { Action } from '@ngrx/store'; - - export const INCREMENT = 'INCREMENT'; - export const DECREMENT = 'DECREMENT'; - export const RESET = 'RESET'; - - const initialState = 0; - - export function counterReducer(state: number = initialState, action: Action) { - switch (action.type) { - case INCREMENT: - return state + 1; - - case DECREMENT: - return state - 1; - - case RESET: - return 0; - - default: - return state; - } - } - `, - })) - // Run the e2e tests against a prod build. - .then(() => ng('e2e', '--prod')); -} diff --git a/tests/legacy-cli/e2e/tests/misc/title.ts b/tests/legacy-cli/e2e/tests/misc/title.ts deleted file mode 100644 index 1e5cc5f763a6..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/title.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { execAndWaitForOutputToMatch, execWithEnv, killAllProcesses } from '../../utils/process'; - - -export default async function() { - try { - await execAndWaitForOutputToMatch('ng', ['build', '--watch'], /./); - - const output = await execWithEnv('ps', ['x'], { COLUMNS: '200' }); - - if (!output.stdout.match(/ng build --watch/)) { - throw new Error('Title of the process was not properly set.'); - } - } finally { - await killAllProcesses(); - } -} diff --git a/tests/legacy-cli/e2e/tests/misc/trace-resolution.ts b/tests/legacy-cli/e2e/tests/misc/trace-resolution.ts deleted file mode 100644 index 30c8113ce9ec..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/trace-resolution.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ng } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; - -export default async function () { - await updateJsonFile('tsconfig.json', tsconfig => { - tsconfig.compilerOptions.traceResolution = true; - }); - - const { stdout: stdoutTraced } = await ng('build'); - if (!/Resolving module/.test(stdoutTraced)) { - throw new Error(`Modules resolutions must be printed when 'traceResolution' is enabled.`); - } - - await updateJsonFile('tsconfig.json', tsconfig => { - tsconfig.compilerOptions.traceResolution = false; - }); - - const { stdout: stdoutUnTraced } = await ng('build'); - if (/Resolving module/.test(stdoutUnTraced)) { - throw new Error(`Modules resolutions must not be printed when 'traceResolution' is disabled.`); - } -} diff --git a/tests/legacy-cli/e2e/tests/misc/typescript-warning.ts b/tests/legacy-cli/e2e/tests/misc/typescript-warning.ts deleted file mode 100644 index f6f10c14a46e..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/typescript-warning.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ng, silentNpm } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; - -export default async function () { - // typescript@2.8.0-dev.20180320 is not part of the officially supported range in latest stable. - const unsupportedTsVersion = '2.8.0-dev.20180320'; - - await updateJsonFile('src/tsconfig.app.json', configJson => { - // skipLibCheck is required because declerations - // emitted in TS 3.1 are not compatable with TS 2.8 - // Thus it will exit with a non zero error code. - configJson.compilerOptions = { - ...configJson.compilerOptions, - skipLibCheck: true, - }; - configJson.angularCompilerOptions = { - ...configJson.angularCompilerOptions, - disableTypeScriptVersionCheck: true, - }; - }); - - return Promise.resolve() - // Warning should show. - .then(() => silentNpm('install', `typescript@${unsupportedTsVersion}`, '--no-save')) - .then(() => ng('build')) - .then((output) => { - if (!output.stderr.match('Using this version can result in undefined behaviour')) { - throw new Error('Expected to have typescript version mismatch warning in output.'); - } - }); - /* - // Warning should be disabled with global flag. - .then(() => ng('config', '--global', 'warnings.typescriptMismatch', 'false')) - .then(() => ng('build')) - .then((output) => { - if (output.stdout.match('Using this version can result in undefined behaviour')) { - throw new Error('Expected to not have typescript version mismatch warning in output.'); - } - }) - .then(() => ng('config', '--global', 'warnings.typescriptMismatch', 'true')) - // Warning should be disabled with local flag. - .then(() => ng('config', 'warnings.typescriptMismatch', 'false')) - .then(() => ng('build')) - .then((output) => { - if (output.stdout.match('Using this version can result in undefined behaviour')) { - throw new Error('Expected to not have typescript version mismatch warning in output.'); - } - }) - .then(() => ng('config', 'warnings.typescriptMismatch', 'true')) - // Cleanup - .then(() => silentNpm('install')); - */ -} - diff --git a/tests/legacy-cli/e2e/tests/misc/update-help.ts b/tests/legacy-cli/e2e/tests/misc/update-help.ts deleted file mode 100644 index 2e98d9fd4a9d..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/update-help.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ng } from '../../utils/process'; - -export default function () { - return Promise.resolve() - .then(() => ng('update', '--help')) - .then(({ stdout }) => { - if (!/next/.test(stdout)) { - throw 'Update help should contain "next" option'; - } - }); -} diff --git a/tests/legacy-cli/e2e/tests/misc/version.ts b/tests/legacy-cli/e2e/tests/misc/version.ts deleted file mode 100644 index 121d1aa123e1..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/version.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { deleteFile } from '../../utils/fs'; -import { ng } from '../../utils/process'; - - -export default async function() { - const { stdout: commandOutput } = await ng('version'); - const { stdout: optionOutput } = await ng('--version'); - if (!optionOutput.includes('Angular CLI:')) { - throw new Error('version not displayed'); - } - if (commandOutput !== optionOutput) { - throw new Error('version variants have differing output'); - } - - // doesn't fail on a project with missing angular.json - await deleteFile('angular.json'); - await ng('version'); - - // Doesn't fail outside a project. - await process.chdir('/'); - await ng('version'); -} diff --git a/tests/legacy-cli/e2e/tests/misc/workspace-verification.ts b/tests/legacy-cli/e2e/tests/misc/workspace-verification.ts deleted file mode 100644 index a9353edf7395..000000000000 --- a/tests/legacy-cli/e2e/tests/misc/workspace-verification.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {deleteFile} from '../../utils/fs'; -import {ng} from '../../utils/process'; -import { expectToFail } from '../../utils/utils'; - - -export default function() { - return ng('generate', 'component', 'foo', '--dry-run') - .then(() => deleteFile('angular.json')) - // fails because it needs to be inside a project - // without a workspace file - .then(() => expectToFail(() => ng('generate', 'component', 'foo', '--dry-run'))) - .then(() => ng('version')); -} diff --git a/tests/legacy-cli/e2e/tests/packages/webpack/server-ng5.ts b/tests/legacy-cli/e2e/tests/packages/webpack/server-ng5.ts deleted file mode 100644 index c2f975d34fe7..000000000000 --- a/tests/legacy-cli/e2e/tests/packages/webpack/server-ng5.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { normalize } from 'path'; -import { createProjectFromAsset } from '../../../utils/assets'; -import { exec } from '../../../utils/process'; -import { expectFileToMatch } from '../../../utils/fs'; - - -export default function (skipCleaning: () => void) { - // This test was broken as it was a copy of the ng2 server test - // and did not actually test ng5 - return; - - return Promise.resolve() - .then(() => createProjectFromAsset('webpack/test-server-app-ng5')) - .then(() => exec(normalize('node_modules/.bin/webpack-cli'))) - .then(() => expectFileToMatch('dist/app.main.js', - new RegExp('MyInjectable.ctorParameters = .*' - + 'type: undefined, decorators.*Inject.*args: .*DOCUMENT.*'))) - .then(() => expectFileToMatch('dist/app.main.js', - new RegExp('AppComponent.ctorParameters = .*MyInjectable'))) - .then(() => expectFileToMatch('dist/app.main.js', - /AppModule \*\/\].*\.testProp = \'testing\'/)) - .then(() => expectFileToMatch('dist/app.main.js', - /platformServer \*\/\]\)\(\)\.bootstrapModuleFactory\(.*\/\* AppModuleNgFactory \*\/\]/)) - .then(() => expectFileToMatch('dist/app.main.js', - /renderModuleFactory \*\/\].*\/\* AppModuleNgFactory \*\/\]/)) - .then(() => skipCleaning()); -} diff --git a/tests/legacy-cli/e2e/tests/packages/webpack/test-ng5.ts b/tests/legacy-cli/e2e/tests/packages/webpack/test-ng5.ts deleted file mode 100644 index 3b8dc3b903a7..000000000000 --- a/tests/legacy-cli/e2e/tests/packages/webpack/test-ng5.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {normalize} from 'path'; -import {createProjectFromAsset} from '../../../utils/assets'; -import {exec} from '../../../utils/process'; -import {expectFileSizeToBeUnder, replaceInFile, expectFileToMatch} from '../../../utils/fs'; - - -export default function(skipCleaning: () => void) { - return Promise.resolve() - .then(() => createProjectFromAsset('webpack/test-app-ng5')) - .then(() => exec(normalize('node_modules/.bin/webpack-cli'))) - .then(() => expectFileSizeToBeUnder('dist/app.main.js', 375000)) - .then(() => expectFileSizeToBeUnder('dist/0.app.main.js', 25000)) - .then(() => expectFileSizeToBeUnder('dist/1.app.main.js', 2000)) - // test resource urls without ./ - .then(() => replaceInFile('app/app.component.ts', - './app.component.html', 'app.component.html')) - .then(() => replaceInFile('app/app.component.ts', - './app.component.scss', 'app.component.scss')) - // test the inclusion of metadata - // This build also test resource URLs without ./ - .then(() => exec(normalize('node_modules/.bin/webpack-cli'), '--mode=development')) - .then(() => expectFileToMatch('dist/app.main.js', 'AppModuleNgFactory')) - .then(() => skipCleaning()); -} diff --git a/tests/legacy-cli/e2e/tests/packages/webpack/weird-ng5.ts b/tests/legacy-cli/e2e/tests/packages/webpack/weird-ng5.ts deleted file mode 100644 index 49546dd3b988..000000000000 --- a/tests/legacy-cli/e2e/tests/packages/webpack/weird-ng5.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {normalize} from 'path'; - -import {createProjectFromAsset} from '../../../utils/assets'; -import {exec} from '../../../utils/process'; -import {updateJsonFile} from '../../../utils/project'; -import {expectFileSizeToBeUnder, expectFileToExist, expectFileToMatch} from '../../../utils/fs'; -import {expectToFail} from '../../../utils/utils'; - - -export default function(skipCleaning: () => void) { - return Promise.resolve() - .then(() => createProjectFromAsset('webpack/test-app-weird-ng5')) - .then(() => exec(normalize('node_modules/.bin/webpack-cli'))) - .then(() => expectFileToExist('dist/app.main.js')) - .then(() => expectFileToExist('dist/0.app.main.js')) - .then(() => expectFileToExist('dist/1.app.main.js')) - .then(() => expectFileToExist('dist/2.app.main.js')) - .then(() => expectFileSizeToBeUnder('dist/app.main.js', 410000)) - .then(() => expectFileSizeToBeUnder('dist/0.app.main.js', 40000)) - - // Verify that we're using the production environment. - .then(() => expectFileToMatch('dist/app.main.js', /PRODUCTION_ONLY/)) - .then(() => expectToFail(() => expectFileToMatch('dist/app.main.js', /DEBUG_ONLY/))) - - // Verify that we're using the debug environment now. - .then(() => updateJsonFile('webpack.flags.json', json => { - json['DEBUG'] = true; - })) - .then(() => exec(normalize('node_modules/.bin/webpack-cli'))) - .then(() => expectFileToMatch('dist/app.main.js', /DEBUG_ONLY/)) - .then(() => expectToFail(() => expectFileToMatch('dist/app.main.js', /PRODUCTION_ONLY/))) - - .then(() => skipCleaning()); -} diff --git a/tests/legacy-cli/e2e/tests/schematics_cli/basic.ts b/tests/legacy-cli/e2e/tests/schematics_cli/basic.ts deleted file mode 100644 index 45e2aed81115..000000000000 --- a/tests/legacy-cli/e2e/tests/schematics_cli/basic.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as path from 'path'; -import { getGlobalVariable } from '../../utils/env'; -import { exec, execAndWaitForOutputToMatch, silentNpm } from '../../utils/process'; - -const packages = require('../../../../../lib/packages').packages; - -export default async function () { - // setup - const argv = getGlobalVariable('argv'); - if (argv.noglobal) { - return; - } - - const startCwd = process.cwd(); - await silentNpm('install', '-g', packages['@angular-devkit/schematics-cli'].tar, '--unsafe-perm'); - await exec(process.platform.startsWith('win') ? 'where' : 'which', 'schematics'); - - // create blank schematic - await exec('schematics', 'schematic', '--name', 'test-schematic'); - - process.chdir(path.join(startCwd, 'test-schematic')); - await execAndWaitForOutputToMatch( - 'schematics', - ['.:', '--list-schematics'], - /my-full-schematic/, - ); - - // restore path - process.chdir(startCwd); - -} diff --git a/tests/legacy-cli/e2e/tests/test/e2e-baseurl.ts b/tests/legacy-cli/e2e/tests/test/e2e-baseurl.ts deleted file mode 100644 index c7abf7c66d97..000000000000 --- a/tests/legacy-cli/e2e/tests/test/e2e-baseurl.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ng, killAllProcesses } from '../../utils/process'; -import { expectToFail } from '../../utils/utils'; -import { ngServe } from '../../utils/project'; -import { updateJsonFile } from '../../utils/project'; - - -export default function () { - // TODO(architect): Figure out why this test is not working. - return; - - return Promise.resolve() - .then(() => expectToFail(() => ng('e2e', 'test-project-e2e', '--devServerTarget='))) - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.serve.options.port = 4400; - })) - .then(() => ngServe()) - .then(() => ng('e2e', 'test-project-e2e', '--devServerTarget=', '--base-url=http://localhost:4400')) - .then(() => ng('e2e', 'test-project-e2e', '--devServerTarget=', '--port=4400')) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }); -} diff --git a/tests/legacy-cli/e2e/tests/test/test-assets.ts b/tests/legacy-cli/e2e/tests/test/test-assets.ts deleted file mode 100644 index d647d07f8d0e..000000000000 --- a/tests/legacy-cli/e2e/tests/test/test-assets.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { writeMultipleFiles } from '../../utils/fs'; -import { ng } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; -import { expectToFail } from '../../utils/utils'; -import { stripIndent } from 'common-tags'; - -// Make sure asset files are served -export default function () { - // TODO(architect): Figure out why this test is not working. - return; - - return Promise.resolve() - .then(() => writeMultipleFiles({ - 'src/assets/file.txt': 'assets-folder-content', - 'src/file.txt': 'file-content', - // Not using `async()` in tests as it seemed to swallow `fetch()` errors - 'src/app/app.component.spec.ts': stripIndent` - describe('Test Runner', () => { - const fetch = global['fetch']; - it('should serve files in assets folder', (done) => { - fetch('/assets/file.txt') - .then(response => response.text()) - .then(fileText => { - expect(fileText).toMatch('assets-folder-content'); - done(); - }); - }); - it('should serve files explicitly added to assets array', (done) => { - fetch('/file.txt') - .then(response => response.text()) - .then(fileText => { - expect(fileText).toMatch('file-content'); - done(); - }); - }); - }); - ` - })) - // Test failure condition (no assets in angular.json) - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project']; - appArchitect.build.options.assets = []; - })) - .then(() => expectToFail(() => ng('test', '--watch=false'), - 'Should fail because the assets to serve were not in the Angular CLI config')) - // Test passing condition (assets are included) - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.assets = [ - { 'glob': '**/*', 'input': 'src/assets' }, - { 'glob': 'file.txt' }, - ]; - })) - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/test/test-environment.ts b/tests/legacy-cli/e2e/tests/test/test-environment.ts deleted file mode 100644 index 09152caee409..000000000000 --- a/tests/legacy-cli/e2e/tests/test/test-environment.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ng } from '../../utils/process'; -import { writeFile } from '../../utils/fs'; -import { updateJsonFile } from '../../utils/project'; - -export default function () { - // Tests run in 'dev' environment by default. - return writeFile('src/app/environment.spec.ts', ` - import { environment } from '../environments/environment'; - - describe('Test environment', () => { - it('should have production disabled', () => { - expect(environment.production).toBe(false); - }); - }); - `) - .then(() => ng('test', '--watch=false')) - .then(() => updateJsonFile('angular.json', configJson => { - const appArchitect = configJson.projects['test-project'].architect; - appArchitect.test.configurations = { - production: { - fileReplacements: [ - { - src: 'src/environments/environment.ts', - replaceWith: 'src/environments/environment.prod.ts', - } - ], - } - }; - })) - - // Tests can run in different environment. - .then(() => writeFile('src/app/environment.spec.ts', ` - import { environment } from '../environments/environment'; - - describe('Test environment', () => { - it('should have production enabled', () => { - expect(environment.production).toBe(true); - }); - }); - `)) - .then(() => ng('test', '--prod', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/test/test-fail-single-run.ts b/tests/legacy-cli/e2e/tests/test/test-fail-single-run.ts deleted file mode 100644 index 62ac51d0199a..000000000000 --- a/tests/legacy-cli/e2e/tests/test/test-fail-single-run.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ng } from '../../utils/process'; -import { writeFile } from '../../utils/fs'; -import { expectToFail } from '../../utils/utils'; - - -export default function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - // Fails on single run with broken compilation. - return writeFile('src/app.component.spec.ts', '

definitely not typescript

') - .then(() => expectToFail(() => ng('test', '--watch=false'))); -} diff --git a/tests/legacy-cli/e2e/tests/test/test-fail-watch.ts b/tests/legacy-cli/e2e/tests/test/test-fail-watch.ts deleted file mode 100644 index 69b28fabaeea..000000000000 --- a/tests/legacy-cli/e2e/tests/test/test-fail-watch.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - killAllProcesses, - waitForAnyProcessOutputToMatch, - execAndWaitForOutputToMatch, -} from '../../utils/process'; -import { expectToFail } from '../../utils/utils'; -import { readFile, writeFile } from '../../utils/fs'; - - -// Karma is only really finished with a run when it shows a non-zero total time in the first slot. -const karmaGoodRegEx = /Executed 3 of 3 SUCCESS \(\d+\.\d+ secs/; - -export default function () { - // TODO(architect): This test is behaving oddly both here and in devkit/build-angular. - // It seems to be because of file watchers. - return; - - let originalSpec: string; - return execAndWaitForOutputToMatch('ng', ['test'], karmaGoodRegEx) - .then(() => readFile('src/app/app.component.spec.ts')) - .then((data) => originalSpec = data) - // Trigger a failed rebuild, which shouldn't run tests again. - .then(() => writeFile('src/app/app.component.spec.ts', '

definitely not typescript

')) - .then(() => expectToFail(() => waitForAnyProcessOutputToMatch(karmaGoodRegEx, 10000))) - // Restore working spec. - .then(() => writeFile('src/app/app.component.spec.ts', originalSpec)) - .then(() => waitForAnyProcessOutputToMatch(karmaGoodRegEx, 20000)) - .then(() => killAllProcesses(), (err: any) => { - killAllProcesses(); - throw err; - }); -} diff --git a/tests/legacy-cli/e2e/tests/test/test-scripts.ts b/tests/legacy-cli/e2e/tests/test/test-scripts.ts deleted file mode 100644 index bf5299647efd..000000000000 --- a/tests/legacy-cli/e2e/tests/test/test-scripts.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { writeMultipleFiles } from '../../utils/fs'; -import { ng } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; -import { expectToFail } from '../../utils/utils'; -import { stripIndent } from 'common-tags'; - - -export default function () { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return Promise.resolve() - .then(() => ng('test', '--watch=false')) - // prepare global scripts test files - .then(() => writeMultipleFiles({ - 'src/string-script.js': `stringScriptGlobal = 'string-scripts.js';`, - 'src/input-script.js': `inputScriptGlobal = 'input-scripts.js';`, - 'src/typings.d.ts': stripIndent` - declare var stringScriptGlobal: any; - declare var inputScriptGlobal: any; - `, - 'src/app/app.component.ts': stripIndent` - import { Component } from '@angular/core'; - - @Component({ selector: 'app-root', template: '' }) - export class AppComponent { - stringScriptGlobalProp = stringScriptGlobal; - inputScriptGlobalProp = inputScriptGlobal; - } - `, - 'src/app/app.component.spec.ts': stripIndent` - import { TestBed, async } from '@angular/core/testing'; - import { AppComponent } from './app.component'; - - describe('AppComponent', () => { - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ AppComponent ] - }).compileComponents(); - })); - - it('should have access to string-script.js', async(() => { - let app = TestBed.createComponent(AppComponent).debugElement.componentInstance; - expect(app.stringScriptGlobalProp).toEqual('string-scripts.js'); - })); - - it('should have access to input-script.js', async(() => { - let app = TestBed.createComponent(AppComponent).debugElement.componentInstance; - expect(app.inputScriptGlobalProp).toEqual('input-scripts.js'); - })); - }); - - describe('Spec', () => { - it('should have access to string-script.js', async(() => { - expect(stringScriptGlobal).toBe('string-scripts.js'); - })); - - it('should have access to input-script.js', async(() => { - expect(inputScriptGlobal).toBe('input-scripts.js'); - })); - }); - ` - })) - // should fail because the global scripts were not added to scripts array - .then(() => expectToFail(() => ng('test', '--watch=false'))) - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.test.options.scripts = [ - { input: 'src/string-script.js' }, - { input: 'src/input-script.js' }, - ]; - })) - // should pass now - .then(() => ng('test', '--watch=false')); -} - diff --git a/tests/legacy-cli/e2e/tests/test/test-target.ts b/tests/legacy-cli/e2e/tests/test/test-target.ts deleted file mode 100644 index ba1afd13d1c2..000000000000 --- a/tests/legacy-cli/e2e/tests/test/test-target.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ng } from '../../utils/process'; -import { updateJsonFile } from '../../utils/project'; - -export default function () { - // TODO(architect): This is giving odd errors in devkit/build-angular. - // TypeError: Assignment to constant variable. - return; - - return updateJsonFile('tsconfig.json', configJson => { - const compilerOptions = configJson['compilerOptions']; - compilerOptions['target'] = 'es2015'; - }) - .then(() => updateJsonFile('src/tsconfig.spec.json', configJson => { - const compilerOptions = configJson['compilerOptions']; - compilerOptions['target'] = 'es2015'; - })) - .then(() => ng('test', '--watch=false')); -} diff --git a/tests/legacy-cli/e2e/tests/third-party/bootstrap.ts b/tests/legacy-cli/e2e/tests/third-party/bootstrap.ts deleted file mode 100644 index 847bce3bba42..000000000000 --- a/tests/legacy-cli/e2e/tests/third-party/bootstrap.ts +++ /dev/null @@ -1,46 +0,0 @@ -import {silentNpm, ng} from '../../utils/process'; -import {updateJsonFile} from '../../utils/project'; -import {expectFileToMatch} from '../../utils/fs'; -import {oneLineTrim} from 'common-tags'; - - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return Promise.resolve() - .then(() => silentNpm('install', 'bootstrap@4.0.0-beta.3')) - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.styles = [ - { input: 'node_modules/bootstrap/dist/css/bootstrap.css' }, - ]; - appArchitect.build.options.scripts = [ - { input: 'node_modules/bootstrap/dist/js/bootstrap.js' }, - ]; - })) - .then(() => ng('build', '--extract-css')) - .then(() => expectFileToMatch('dist/test-project/scripts.js', '* Bootstrap')) - .then(() => expectFileToMatch('dist/test-project/styles.css', '* Bootstrap')) - .then(() => expectFileToMatch('dist/test-project/index.html', oneLineTrim` - - - - - - `)) - .then(() => ng( - 'build', - '--optimization', - '--extract-css', - '--output-hashing=none', - '--vendor-chunk=false', - )) - .then(() => expectFileToMatch('dist/test-project/scripts.js', 'jQuery')) - .then(() => expectFileToMatch('dist/test-project/styles.css', '* Bootstrap')) - .then(() => expectFileToMatch('dist/test-project/index.html', oneLineTrim` - - - - - `)); -} diff --git a/tests/legacy-cli/e2e/tests/third-party/material-icons.ts b/tests/legacy-cli/e2e/tests/third-party/material-icons.ts deleted file mode 100644 index 07d5ac97b904..000000000000 --- a/tests/legacy-cli/e2e/tests/third-party/material-icons.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {silentNpm, ng} from '../../utils/process'; -import {updateJsonFile} from '../../utils/project'; -import {expectFileToMatch} from '../../utils/fs'; - - -export default function() { - // TODO(architect): Delete this test. It is now in devkit/build-angular. - - return Promise.resolve() - .then(() => silentNpm('install', 'material-design-icons@3.0.1')) - .then(() => updateJsonFile('angular.json', workspaceJson => { - const appArchitect = workspaceJson.projects['test-project'].architect; - appArchitect.build.options.styles = [ - { input: 'node_modules/material-design-icons/iconfont/material-icons.css' }, - ]; - })) - .then(() => ng('build', '--extract-css')) - .then(() => expectFileToMatch('dist/test-project/styles.css', 'Material Icons')) - .then(() => ng( - 'build', - '--prod', - '--extract-css', - '--output-hashing=none' - )) - .then(() => expectFileToMatch('dist/test-project/styles.css', 'Material Icons')); -} diff --git a/tests/legacy-cli/e2e/utils/assets.ts b/tests/legacy-cli/e2e/utils/assets.ts deleted file mode 100644 index 83266c792b29..000000000000 --- a/tests/legacy-cli/e2e/utils/assets.ts +++ /dev/null @@ -1,48 +0,0 @@ -import {join} from 'path'; -import * as glob from 'glob'; -import {getGlobalVariable} from './env'; -import {relative} from 'path'; -import {copyFile} from './fs'; -import {useBuiltPackages} from './project'; -import { git, silentNpm } from './process'; - - -export function assetDir(assetName: string) { - return join(__dirname, '../assets', assetName); -} - -export function copyProjectAsset(assetName: string, to?: string) { - const tempRoot = join(getGlobalVariable('tmp-root'), 'test-project'); - const sourcePath = assetDir(assetName); - const targetPath = join(tempRoot, to || assetName); - - return copyFile(sourcePath, targetPath); -} - -export function copyAssets(assetName: string) { - const seed = +Date.now(); - const tempRoot = join(getGlobalVariable('tmp-root'), 'assets', assetName + '-' + seed); - const root = assetDir(assetName); - - return Promise.resolve() - .then(() => { - const allFiles = glob.sync(join(root, '**/*'), { dot: true, nodir: true }); - - return allFiles.reduce((promise, filePath) => { - const relPath = relative(root, filePath); - const toPath = join(tempRoot, relPath); - - return promise.then(() => copyFile(filePath, toPath)); - }, Promise.resolve()); - }) - .then(() => tempRoot); -} - - -export function createProjectFromAsset(assetName: string) { - return Promise.resolve() - .then(() => copyAssets(assetName)) - .then(dir => process.chdir(dir)) - .then(() => useBuiltPackages()) - .then(() => silentNpm('install')); -} diff --git a/tests/legacy-cli/e2e/utils/env.ts b/tests/legacy-cli/e2e/utils/env.ts deleted file mode 100644 index dd80596f0698..000000000000 --- a/tests/legacy-cli/e2e/utils/env.ts +++ /dev/null @@ -1,13 +0,0 @@ -const global: {[name: string]: any} = Object.create(null); - - -export function setGlobalVariable(name: string, value: any) { - global[name] = value; -} - -export function getGlobalVariable(name: string): any { - if (!(name in global)) { - throw new Error(`Trying to access variable "${name}" but it's not defined.`); - } - return global[name]; -} diff --git a/tests/legacy-cli/e2e/utils/fs.ts b/tests/legacy-cli/e2e/utils/fs.ts deleted file mode 100644 index 4beb03021e1f..000000000000 --- a/tests/legacy-cli/e2e/utils/fs.ts +++ /dev/null @@ -1,207 +0,0 @@ -import * as fs from 'fs-extra'; -import {dirname} from 'path'; -import {stripIndents} from 'common-tags'; - - -export function readFile(fileName: string) { - return new Promise((resolve, reject) => { - fs.readFile(fileName, 'utf-8', (err: any, data: string) => { - if (err) { - reject(err); - } else { - resolve(data); - } - }); - }); -} - -export function writeFile(fileName: string, content: string, options?: any) { - return new Promise((resolve, reject) => { - fs.writeFile(fileName, content, options, (err: any) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); -} - - -export function deleteFile(path: string) { - return new Promise((resolve, reject) => { - fs.unlink(path, (err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); -} - - -export function rimraf(path: string) { - return new Promise((resolve, reject) => { - fs.remove(path, (err?: any) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); -} - - -export function moveFile(from: string, to: string) { - return new Promise((resolve, reject) => { - fs.rename(from, to, (err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); -} - - -export function symlinkFile(from: string, to: string, type?: string) { - return new Promise((resolve, reject) => { - fs.symlink(from, to, type, (err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); -} - -export function createDir(path: string) { - return _recursiveMkDir(path); -} - - -function _recursiveMkDir(path: string): Promise { - if (fs.existsSync(path)) { - return Promise.resolve(); - } else { - return _recursiveMkDir(dirname(path)) - .then(() => fs.mkdirSync(path)); - } -} - -export function copyFile(from: string, to: string) { - return _recursiveMkDir(dirname(to)) - .then(() => new Promise((resolve, reject) => { - const rd = fs.createReadStream(from); - rd.on('error', (err: Error) => reject(err)); - - const wr = fs.createWriteStream(to); - wr.on('error', (err: Error) => reject(err)); - wr.on('close', () => resolve()); - - rd.pipe(wr); - })); -} - -export function moveDirectory(from: string, to: string) { - return fs.move(from, to, { overwrite: true }); -} - - -export function writeMultipleFiles(fs: { [path: string]: string }) { - return Promise.all(Object.keys(fs).map(fileName => writeFile(fileName, fs[fileName]))); -} - - -export function replaceInFile(filePath: string, match: RegExp | string, replacement: string) { - return readFile(filePath) - .then((content: string) => writeFile(filePath, content.replace(match, replacement))); -} - - -export function appendToFile(filePath: string, text: string, options?: any) { - return readFile(filePath) - .then((content: string) => writeFile(filePath, content.concat(text), options)); -} - - -export function prependToFile(filePath: string, text: string, options?: any) { - return readFile(filePath) - .then((content: string) => writeFile(filePath, text.concat(content), options)); -} - - -export function expectFileMatchToExist(dir: string, regex: RegExp) { - return new Promise((resolve, reject) => { - const [fileName] = fs.readdirSync(dir).filter(name => name.match(regex)); - if (!fileName) { - reject(new Error(`File ${regex} was expected to exist but not found...`)); - } - resolve(fileName); - }); -} - -export function expectFileNotToExist(fileName: string) { - return new Promise((resolve, reject) => { - fs.exists(fileName, (exist) => { - if (exist) { - reject(new Error(`File ${fileName} was expected not to exist but found...`)); - } else { - resolve(); - } - }); - }); -} - -export function expectFileToExist(fileName: string) { - return new Promise((resolve, reject) => { - fs.exists(fileName, (exist) => { - if (exist) { - resolve(); - } else { - reject(new Error(`File ${fileName} was expected to exist but not found...`)); - } - }); - }); -} - -export function expectFileToMatch(fileName: string, regEx: RegExp | string) { - return readFile(fileName) - .then(content => { - if (typeof regEx == 'string') { - if (content.indexOf(regEx) == -1) { - throw new Error(stripIndents`File "${fileName}" did not contain "${regEx}"... - Content: - ${content} - ------ - `); - } - } else { - if (!content.match(regEx)) { - throw new Error(stripIndents`File "${fileName}" did not contain "${regEx}"... - Content: - ${content} - ------ - `); - } - } - }); -} - -export async function getFileSize(fileName: string) { - const stats = await fs.stat(fileName); - - return stats.size; -} - -export async function expectFileSizeToBeUnder(fileName: string, sizeInBytes: number) { - const fileSize = await getFileSize(fileName); - - if (fileSize > sizeInBytes) { - throw new Error(`File "${fileName}" exceeded file size of "${sizeInBytes}".`); - } -} diff --git a/tests/legacy-cli/e2e/utils/git.ts b/tests/legacy-cli/e2e/utils/git.ts deleted file mode 100644 index 7da09d308c01..000000000000 --- a/tests/legacy-cli/e2e/utils/git.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {git, silentGit} from './process'; - - -export function gitClean() { - console.log(' Cleaning git...'); - return silentGit('clean', '-df') - .then(() => silentGit('reset', '--hard')) - .then(() => { - // Checkout missing files - return silentGit('status', '--porcelain') - .then(({ stdout }) => stdout - .split(/[\n\r]+/g) - .filter(line => line.match(/^ D/)) - .map(line => line.replace(/^\s*\S+\s+/, ''))) - .then(files => silentGit('checkout', ...files)); - }) - .then(() => expectGitToBeClean()); -} - -export function expectGitToBeClean() { - return silentGit('status', '--porcelain') - .then(({ stdout }) => { - if (stdout != '') { - throw new Error('Git repo is not clean...\n' + stdout); - } - }); -} - -export function gitCommit(message: string) { - return git('add', '-A') - .then(() => silentGit('status', '--porcelain')) - .then(({ stdout }) => { - if (stdout != '') { - return git('commit', '-am', message); - } - }); -} diff --git a/tests/legacy-cli/e2e/utils/http.ts b/tests/legacy-cli/e2e/utils/http.ts deleted file mode 100644 index fa0fbc32c3c5..000000000000 --- a/tests/legacy-cli/e2e/utils/http.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {IncomingMessage} from 'http'; -import * as _request from 'request'; - - -export function request(url: string): Promise { - return new Promise((resolve, reject) => { - let options = { - url: url, - headers: { 'Accept': 'text/html' }, - agentOptions: { rejectUnauthorized: false } - }; - _request(options, (error: any, response: IncomingMessage, body: string) => { - if (error) { - reject(error); - } else if (response.statusCode >= 400) { - reject(new Error(`Requesting "${url}" returned status code ${response.statusCode}.`)); - } else { - resolve(body); - } - }); - }); -} diff --git a/tests/legacy-cli/e2e/utils/process.ts b/tests/legacy-cli/e2e/utils/process.ts deleted file mode 100644 index 31bd37a724ff..000000000000 --- a/tests/legacy-cli/e2e/utils/process.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { SpawnOptions } from "child_process"; -import * as child_process from 'child_process'; -import { terminal } from '@angular-devkit/core'; -import { Observable, concat, defer, EMPTY, from} from 'rxjs'; -import {repeat, takeLast} from 'rxjs/operators'; -import {getGlobalVariable} from './env'; -import {rimraf} from './fs'; -import {catchError} from 'rxjs/operators'; -const treeKill = require('tree-kill'); - - -interface ExecOptions { - silent?: boolean; - waitForMatch?: RegExp; - env?: { [varname: string]: string }; -} - - -let _processes: child_process.ChildProcess[] = []; - -export type ProcessOutput = { - stdout: string; - stderr: string; -}; - - -function _exec(options: ExecOptions, cmd: string, args: string[]): Promise { - let stdout = ''; - let stderr = ''; - const cwd = process.cwd(); - const env = options.env; - console.log( - `==========================================================================================` - ); - - args = args.filter(x => x !== undefined); - const flags = [ - options.silent && 'silent', - options.waitForMatch && `matching(${options.waitForMatch})` - ] - .filter(x => !!x) // Remove false and undefined. - .join(', ') - .replace(/^(.+)$/, ' [$1]'); // Proper formatting. - - console.log(terminal.blue(`Running \`${cmd} ${args.map(x => `"${x}"`).join(' ')}\`${flags}...`)); - console.log(terminal.blue(`CWD: ${cwd}`)); - console.log(terminal.blue(`ENV: ${JSON.stringify(env)}`)); - const spawnOptions: SpawnOptions = { - cwd, - ...env ? { env } : {}, - }; - - if (process.platform.startsWith('win')) { - args.unshift('/c', cmd); - cmd = 'cmd.exe'; - spawnOptions['stdio'] = 'pipe'; - } - - const childProcess = child_process.spawn(cmd, args, spawnOptions); - childProcess.stdout.on('data', (data: Buffer) => { - stdout += data.toString('utf-8'); - if (options.silent) { - return; - } - data.toString('utf-8') - .split(/[\n\r]+/) - .filter(line => line !== '') - .forEach(line => console.log(' ' + line)); - }); - childProcess.stderr.on('data', (data: Buffer) => { - stderr += data.toString('utf-8'); - if (options.silent) { - return; - } - data.toString('utf-8') - .split(/[\n\r]+/) - .filter(line => line !== '') - .forEach(line => console.error(terminal.yellow(' ' + line))); - }); - - _processes.push(childProcess); - - // Create the error here so the stack shows who called this function. - const err = new Error(`Running "${cmd} ${args.join(' ')}" returned error code `); - return new Promise((resolve, reject) => { - childProcess.on('exit', (error: any) => { - _processes = _processes.filter(p => p !== childProcess); - - if (!error) { - resolve({ stdout, stderr }); - } else { - err.message += `${error}...\n\nSTDOUT:\n${stdout}\n\nSTDERR:\n${stderr}\n`; - reject(err); - } - }); - - if (options.waitForMatch) { - const match = options.waitForMatch; - childProcess.stdout.on('data', (data: Buffer) => { - if (data.toString().match(match)) { - resolve({ stdout, stderr }); - } - }); - childProcess.stderr.on('data', (data: Buffer) => { - if (data.toString().match(match)) { - resolve({ stdout, stderr }); - } - }); - } - }); -} - -export function waitForAnyProcessOutputToMatch(match: RegExp, - timeout = 30000): Promise { - // Race between _all_ processes, and the timeout. First one to resolve/reject wins. - const timeoutPromise: Promise = new Promise((_resolve, reject) => { - // Wait for 30 seconds and timeout. - setTimeout(() => { - reject(new Error(`Waiting for ${match} timed out (timeout: ${timeout}msec)...`)); - }, timeout); - }); - - const matchPromises: Promise[] = _processes.map( - childProcess => new Promise(resolve => { - let stdout = ''; - let stderr = ''; - childProcess.stdout.on('data', (data: Buffer) => { - stdout += data.toString(); - if (data.toString().match(match)) { - resolve({ stdout, stderr }); - } - }); - childProcess.stderr.on('data', (data: Buffer) => { - stderr += data.toString(); - if (data.toString().match(match)) { - resolve({ stdout, stderr }); - } - }); - })); - - return Promise.race(matchPromises.concat([timeoutPromise])); -} - -export function killAllProcesses(signal = 'SIGTERM') { - _processes.forEach(process => treeKill(process.pid, signal)); - _processes = []; -} - -export function exec(cmd: string, ...args: string[]) { - return _exec({}, cmd, args); -} - -export function silentExec(cmd: string, ...args: string[]) { - return _exec({ silent: true }, cmd, args); -} - -export function execWithEnv(cmd: string, args: string[], env: { [varname: string]: string }) { - return _exec({ env }, cmd, args); -} - -export function execAndWaitForOutputToMatch(cmd: string, args: string[], match: RegExp) { - if (cmd === 'ng' && args[0] === 'serve') { - // Accept matches up to 20 times after the initial match. - // Useful because the Webpack watcher can rebuild a few times due to files changes that - // happened just before the build (e.g. `git clean`). - // This seems to be due to host file system differences, see - // https://nodejs.org/docs/latest/api/fs.html#fs_caveats - return concat( - from( - _exec({ waitForMatch: match }, cmd, args) - ), - defer(() => waitForAnyProcessOutputToMatch(match, 2500)).pipe( - repeat(20), - catchError(() => EMPTY), - ), - ).pipe( - takeLast(1), - ).toPromise(); - } else { - return _exec({ waitForMatch: match }, cmd, args); - } -} - -let npmInstalledEject = false; -export function ng(...args: string[]) { - const argv = getGlobalVariable('argv'); - const maybeSilentNg = argv['nosilent'] ? noSilentNg : silentNg; - if (['build', 'serve', 'test', 'e2e', 'xi18n'].indexOf(args[0]) != -1) { - // If we have the --eject, use webpack for the test. - if (args[0] == 'build' && argv.eject) { - return maybeSilentNg('eject', ...args.slice(1), '--force') - .then(() => { - if (!npmInstalledEject) { - npmInstalledEject = true; - // We need to delete node_modules, then run npm install on the first eject. - return rimraf('node_modules').then(() => silentNpm('install')); - } - }) - .then(() => rimraf('dist')) - .then(() => _exec({silent: true}, 'node_modules/.bin/webpack', [])); - } else if (args[0] == 'e2e') { - // Wait 1 second before running any end-to-end test. - return new Promise(resolve => setTimeout(resolve, 1000)) - .then(() => maybeSilentNg(...args)); - } - - return maybeSilentNg(...args); - } else { - return noSilentNg(...args); - } -} - -export function noSilentNg(...args: string[]) { - return _exec({}, 'ng', args); -} - -export function silentNg(...args: string[]) { - return _exec({silent: true}, 'ng', args); -} - -export function silentNpm(...args: string[]) { - return _exec({silent: true}, 'npm', args); -} - -export function npm(...args: string[]) { - return _exec({}, 'npm', args); -} - -export function node(...args: string[]) { - return _exec({}, 'node', args); -} - -export function git(...args: string[]) { - return _exec({}, 'git', args); -} - -export function silentGit(...args: string[]) { - return _exec({silent: true}, 'git', args); -} diff --git a/tests/legacy-cli/e2e/utils/project.ts b/tests/legacy-cli/e2e/utils/project.ts deleted file mode 100644 index ca292e3c682e..000000000000 --- a/tests/legacy-cli/e2e/utils/project.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { readFile, writeFile, replaceInFile } from './fs'; -import { execAndWaitForOutputToMatch, npm, silentNpm, ng } from './process'; -import { getGlobalVariable } from './env'; - -const packages = require('../../../../lib/packages').packages; - - -const tsConfigPath = 'tsconfig.json'; - - -export function updateJsonFile(filePath: string, fn: (json: any) => any | void) { - return readFile(filePath) - .then(tsConfigJson => { - const tsConfig = JSON.parse(tsConfigJson); - const result = fn(tsConfig) || tsConfig; - - return writeFile(filePath, JSON.stringify(result, null, 2)); - }); -} - - -export function updateTsConfig(fn: (json: any) => any | void) { - return updateJsonFile(tsConfigPath, fn); -} - - -export function ngServe(...args: string[]) { - return execAndWaitForOutputToMatch('ng', - ['serve', ...args], - /: Compiled successfully./); -} - - -export function createProject(name: string, ...args: string[]) { - const argv: any = getGlobalVariable('argv'); - - return Promise.resolve() - .then(() => process.chdir(getGlobalVariable('tmp-root'))) - .then(() => ng('new', name, '--skip-install', ...args)) - .then(() => process.chdir(name)) - .then(() => useBuiltPackages()) - .then(() => useCIChrome('e2e')) - .then(() => useCIChrome('src')) - .then(() => useDevKitSnapshots()) - .then(() => argv['ng2'] ? useNg2() : Promise.resolve()) - .then(() => argv['ng4'] ? useNg4() : Promise.resolve()) - .then(() => argv['ng-snapshots'] || argv['ng-tag'] ? useSha() : Promise.resolve()) - .then(() => console.log(`Project ${name} created... Installing npm.`)) - .then(() => silentNpm('install')) - .then(() => useCIDefaults(name)); -} - - -export function useDevKit(devkitRoot: string) { - return Promise.resolve() - .then(() => { - // Load the packages info for devkit. - const devkitPackages = require(devkitRoot + '/lib/packages').packages; - - return updateJsonFile('package.json', json => { - if (!json['dependencies']) { - json['dependencies'] = {}; - } - if (!json['devDependencies']) { - json['devDependencies'] = {}; - } - - for (const packageName of Object.keys(devkitPackages)) { - if (json['dependencies'].hasOwnProperty(packageName)) { - json['dependencies'][packageName] = devkitPackages[packageName].tar; - } else if (json['devDependencies'].hasOwnProperty(packageName)) { - json['devDependencies'][packageName] = devkitPackages[packageName].tar; - } - } - }); - }); -} - -export function useDevKitSnapshots() { - return updateJsonFile('package.json', json => { - // TODO: actually add these. - // These were not working on any test that ran `npm i`. - // json['devDependencies']['@angular-devkit/build-angular'] = - // 'github:angular/angular-devkit-build-angular-builds'; - // // By adding build-ng-packagr preemptively, adding a lib will not update it. - // json['devDependencies']['@angular-devkit/build-ng-packagr'] = - // 'github:angular/angular-devkit-build-ng-packagr-builds'; - }); -} - -export function useBuiltPackages() { - return Promise.resolve() - .then(() => updateJsonFile('package.json', json => { - if (!json['dependencies']) { - json['dependencies'] = {}; - } - if (!json['devDependencies']) { - json['devDependencies'] = {}; - } - - for (const packageName of Object.keys(packages)) { - if (json['dependencies'].hasOwnProperty(packageName)) { - json['dependencies'][packageName] = packages[packageName].tar; - } else if (json['devDependencies'].hasOwnProperty(packageName)) { - json['devDependencies'][packageName] = packages[packageName].tar; - } - } - })); -} - -export function useSha() { - const argv = getGlobalVariable('argv'); - if (argv['ng-snapshots'] || argv['ng-tag']) { - // We need more than the sha here, version is also needed. Examples of latest tags: - // 7.0.0-beta.4+dd2a650 - // 6.1.6+4a8d56a - const label = argv['ng-tag'] ? argv['ng-tag'] : ''; - return updateJsonFile('package.json', json => { - // Install over the project with snapshot builds. - Object.keys(json['dependencies'] || {}) - .filter(name => name.match(/^@angular\//)) - .forEach(name => { - const pkgName = name.split(/\//)[1]; - if (pkgName == 'cli') { - return; - } - json['dependencies'][`@angular/${pkgName}`] - = `github:angular/${pkgName}-builds${label}`; - }); - - Object.keys(json['devDependencies'] || {}) - .filter(name => name.match(/^@angular\//)) - .forEach(name => { - const pkgName = name.split(/\//)[1]; - if (pkgName == 'cli') { - return; - } - json['devDependencies'][`@angular/${pkgName}`] - = `github:angular/${pkgName}-builds${label}`; - }); - - json['devDependencies']['typescript'] = '~3.1.1'; - }); - } else { - return Promise.resolve(); - } -} - -export function useNgVersion(version: string) { - return updateJsonFile('package.json', json => { - // Install over the project with specific versions. - Object.keys(json['dependencies'] || {}) - .filter(name => name.match(/^@angular\//)) - .forEach(name => { - const pkgName = name.split(/\//)[1]; - if (pkgName == 'cli') { - return; - } - json['dependencies'][`@angular/${pkgName}`] = version; - }); - - Object.keys(json['devDependencies'] || {}) - .filter(name => name.match(/^@angular\//)) - .forEach(name => { - const pkgName = name.split(/\//)[1]; - if (pkgName == 'cli') { - return; - } - json['devDependencies'][`@angular/${pkgName}`] = version; - }); - // TODO: determine the appropriate version for the Angular version - if (version.startsWith('^5')) { - json['devDependencies']['typescript'] = '~2.5.0'; - json['dependencies']['rxjs'] = '5.5.8'; - } else { - json['devDependencies']['typescript'] = '~2.7.0'; - json['dependencies']['rxjs'] = '6.0.0-rc.0'; - } - }); -} - -export function useCIDefaults(projectName = 'test-project') { - return updateJsonFile('angular.json', workspaceJson => { - // Disable progress reporting on CI to reduce spam. - const project = workspaceJson.projects[projectName]; - const appTargets = project.targets || project.architect; - appTargets.build.options.progress = false; - appTargets.test.options.progress = false; - // Disable auto-updating webdriver in e2e. - const e2eProject = workspaceJson.projects[projectName + '-e2e']; - const e2eTargets = e2eProject.targets || e2eProject.architect; - e2eTargets.e2e.options.webdriverUpdate = false; - }) - .then(() => updateJsonFile('package.json', json => { - // We want to always use the same version of webdriver but can only do so on CircleCI. - // Appveyor and Travis will use latest Chrome stable. - // CircleCI (via ngcontainer:0.1.1) uses Chrome 63.0.3239.84. - // Appveyor (via chocolatey) cannot use older versions of Chrome at all: - // https://github.com/chocolatey/chocolatey-coreteampackages/tree/master/automatic/googlechrome - // webdriver 2.33 matches Chrome 63.0.3239.84. - // webdriver 2.37 matches Chrome 65.0.3325.18100 (latest stable). - // The webdriver versions for latest stable will need to be manually updated. - const webdriverVersion = process.env['CIRCLECI'] ? '2.33' : '2.37'; - const driverOption = process.env['CHROMEDRIVER_VERSION_ARG'] - || `--versions.chrome ${webdriverVersion}`; - json['scripts']['webdriver-update'] = 'webdriver-manager update' + - ` --standalone false --gecko false ${driverOption}`; - })) - .then(() => npm('run', 'webdriver-update')); -} - -export function useCIChrome(projectDir: string) { - // There's a race condition happening in Chrome. Enabling logging in chrome used by - // protractor actually fixes it. Logging is piped to a file so it doesn't affect our setup. - // --no-sandbox is needed for Circle CI. - // Travis can use headless chrome, but not appveyor. - return Promise.resolve() - .then(() => replaceInFile(`${projectDir}/protractor.conf.js`, - `'browserName': 'chrome'`, - `'browserName': 'chrome', - chromeOptions: { - args: [ - "--enable-logging", - // "--no-sandbox", - // "--headless" - ] - } - `)) - // Not a problem if the file can't be found. - // .catch(() => null) - // .then(() => replaceInFile(`${projectDir}/karma.conf.js`, `browsers: ['Chrome'],`, - // `browsers: ['ChromeCI'], - // customLaunchers: { - // ChromeCI: { - // base: 'ChromeHeadless', - // flags: ['--no-sandbox'] - // } - // }, - // `)) - .catch(() => null); -} - -// Convert a Angular 5 project to Angular 2. -export function useNg2() { - const ng2Deps: any = { - 'dependencies': { - '@angular/common': '^2.4.0', - '@angular/compiler': '^2.4.0', - '@angular/core': '^2.4.0', - '@angular/forms': '^2.4.0', - '@angular/http': '^2.4.0', - '@angular/platform-browser': '^2.4.0', - '@angular/platform-browser-dynamic': '^2.4.0', - '@angular/router': '^3.4.0', - 'zone.js': '^0.7.4' - }, - 'devDependencies': { - '@angular/compiler-cli': '^2.4.0', - '@types/jasmine': '~2.2.0', - '@types/jasminewd2': undefined, - 'typescript': '~2.0.0' - } - }; - - const tsconfigAppJson: any = { - 'compilerOptions': { - 'sourceMap': true, - 'declaration': false, - 'moduleResolution': 'node', - 'emitDecoratorMetadata': true, - 'experimentalDecorators': true, - 'target': 'es5', - 'lib': [ - 'es2017', - 'dom' - ], - 'outDir': '../out-tsc/app', - 'module': 'es2015', - 'baseUrl': '', - 'types': [] - }, - 'exclude': [ - 'test.ts', - '**/*.spec.ts' - ] - }; - - const tsconfigSpecJson: any = { - 'compilerOptions': { - 'sourceMap': true, - 'declaration': false, - 'moduleResolution': 'node', - 'emitDecoratorMetadata': true, - 'experimentalDecorators': true, - 'lib': [ - 'es2017', - 'dom' - ], - 'outDir': '../out-tsc/spec', - 'module': 'commonjs', - 'target': 'es5', - 'baseUrl': '', - 'types': [ - 'jasmine', - 'node' - ] - }, - 'files': [ - 'test.ts' - ], - 'include': [ - '**/*.spec.ts', - '**/*.d.ts' - ] - }; - - const tsconfigE2eJson: any = { - 'compilerOptions': { - 'sourceMap': true, - 'declaration': false, - 'moduleResolution': 'node', - 'emitDecoratorMetadata': true, - 'experimentalDecorators': true, - 'lib': [ - 'es2017' - ], - 'outDir': '../out-tsc/e2e', - 'module': 'commonjs', - 'target': 'es5', - 'types': [ - 'jasmine', - 'node' - ] - } - }; - - - return Promise.resolve() - .then(() => updateJsonFile('package.json', json => { - Object.keys(ng2Deps['dependencies']).forEach(pkgName => { - json['dependencies'][pkgName] = ng2Deps['dependencies'][pkgName]; - }); - Object.keys(ng2Deps['devDependencies']).forEach(pkgName => { - json['devDependencies'][pkgName] = ng2Deps['devDependencies'][pkgName]; - }); - console.log(JSON.stringify(json)); - })) - .then(() => updateJsonFile('src/tsconfig.app.json', json => - Object.assign(json, tsconfigAppJson))) - .then(() => updateJsonFile('src/tsconfig.spec.json', json => - Object.assign(json, tsconfigSpecJson))) - .then(() => updateJsonFile('e2e/tsconfig.e2e.json', json => - Object.assign(json, tsconfigE2eJson))) - .then(() => replaceInFile('src/test.ts', 'import \'zone.js/dist/zone-testing\';', ` - import 'zone.js/dist/long-stack-trace-zone'; - import 'zone.js/dist/proxy.js'; - import 'zone.js/dist/sync-test'; - import 'zone.js/dist/jasmine-patch'; - import 'zone.js/dist/async-test'; - import 'zone.js/dist/fake-async-test'; - `)); -} - -// Convert a Angular 5 project to Angular 4. -export function useNg4() { - const ng4Deps: any = { - 'dependencies': { - '@angular/common': '^4.4.6', - '@angular/compiler': '^4.4.6', - '@angular/core': '^4.4.6', - '@angular/forms': '^4.4.6', - '@angular/http': '^4.4.6', - '@angular/platform-browser': '^4.4.6', - '@angular/platform-browser-dynamic': '^4.4.6', - '@angular/router': '^4.4.6', - 'zone.js': '^0.8.14' - }, - 'devDependencies': { - '@angular/compiler-cli': '^4.4.6', - 'typescript': '~2.3.3' - } - }; - - - return Promise.resolve() - .then(() => updateJsonFile('package.json', json => { - Object.keys(ng4Deps['dependencies']).forEach(pkgName => { - json['dependencies'][pkgName] = ng4Deps['dependencies'][pkgName]; - }); - Object.keys(ng4Deps['devDependencies']).forEach(pkgName => { - json['devDependencies'][pkgName] = ng4Deps['devDependencies'][pkgName]; - }); - console.log(JSON.stringify(json)); - })); -} diff --git a/tests/legacy-cli/e2e/utils/utils.ts b/tests/legacy-cli/e2e/utils/utils.ts deleted file mode 100644 index 3f73ec59ba94..000000000000 --- a/tests/legacy-cli/e2e/utils/utils.ts +++ /dev/null @@ -1,16 +0,0 @@ - -export function expectToFail(fn: () => Promise, errorMessage?: string): Promise { - return fn() - .then(() => { - const functionSource = fn.name || (fn).source || fn.toString(); - const errorDetails = errorMessage ? `\n\tDetails:\n\t${errorMessage}` : ''; - throw new Error( - `Function ${functionSource} was expected to fail, but succeeded.${errorDetails}`); - }, (err) => { return err; }); -} - -export function wait(msecs: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, msecs); - }); -} diff --git a/tests/legacy-cli/e2e/utils/version.ts b/tests/legacy-cli/e2e/utils/version.ts deleted file mode 100644 index 0be47e2216e3..000000000000 --- a/tests/legacy-cli/e2e/utils/version.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as fs from 'fs'; -import * as semver from 'semver'; - - -export function readNgVersion(): string { - const packageJson: any = JSON.parse(fs.readFileSync('./node_modules/@angular/core/package.json', 'utf8')); - return packageJson['version']; -} - -export function ngVersionMatches(range: string): boolean { - return semver.satisfies(readNgVersion(), range); -} diff --git a/tests/legacy-cli/e2e_runner.ts b/tests/legacy-cli/e2e_runner.ts deleted file mode 100644 index 0f2db025b2bf..000000000000 --- a/tests/legacy-cli/e2e_runner.ts +++ /dev/null @@ -1,277 +0,0 @@ -// This may seem awkward but we're using Logger in our e2e. At this point the unit tests -// have run already so it should be "safe", teehee. -import { logging } from '@angular-devkit/core'; -import { terminal } from '@angular-devkit/core'; -import { createConsoleLogger } from '@angular-devkit/core/node'; -import * as glob from 'glob'; -import * as minimist from 'minimist'; -import * as path from 'path'; -import { setGlobalVariable } from './e2e/utils/env'; -import { gitClean } from './e2e/utils/git'; - -// RxJS -import { filter } from 'rxjs/operators'; - -const { blue, bold, green, red, yellow, white } = terminal; - - -Error.stackTraceLimit = Infinity; - - -/** - * Here's a short description of those flags: - * --debug If a test fails, block the thread so the temporary directory isn't deleted. - * --noproject Skip creating a project or using one. - * --nobuild Skip building the packages. Use with --noglobal and --reuse to quickly - * rerun tests. - * --noglobal Skip linking your local @angular/cli directory. Can save a few seconds. - * --nosilent Never silence ng commands. - * --ng-tag=TAG Use a specific tag for build snapshots. Similar to ng-snapshots but point to a - * tag of using the latest master. - * --ng-snapshots Install angular snapshot builds in the test project. - * --glob Run tests matching this glob pattern (relative to tests/e2e/). - * --ignore Ignore tests matching this glob pattern. - * --reuse=/path Use a path instead of create a new project. That project should have been - * created, and npm installed. Ideally you want a project created by a previous - * run of e2e. - * --nb-shards Total number of shards that this is part of. Default is 2 if --shard is - * passed in. - * --shard Index of this processes' shard. - * --devkit=path Path to the devkit to use. The devkit will be built prior to running. - * --tmpdir=path Override temporary directory to use for new projects. - * If unnamed flags are passed in, the list of tests will be filtered to include only those passed. - */ -const argv = minimist(process.argv.slice(2), { - 'boolean': [ - 'appveyor', - 'debug', - 'eject', - 'ng-snapshots', - 'noglobal', - 'nosilent', - 'noproject', - 'verbose', - ], - 'string': ['devkit', 'glob', 'ignore', 'reuse', 'ng-tag', 'tmpdir', 'ng-version'], - 'number': ['nb-shards', 'shard'], -}); - - -/** - * Set the error code of the process to 255. This is to ensure that if something forces node - * to exit without finishing properly, the error code will be 255. Right now that code is not used. - * - * - 1 When tests succeed we already call `process.exit(0)`, so this doesn't change any correct - * behaviour. - * - * One such case that would force node <= v6 to exit with code 0, is a Promise that doesn't resolve. - */ -process.exitCode = 255; - - -const logger = createConsoleLogger(argv.verbose); -const logStack = [logger]; -function lastLogger() { - return logStack[logStack.length - 1]; -} - -// This code doesn't work and I have no idea why and no intention to investigate at this point. -// (console as any).debug = (msg: string, ...args: any[]) => { -// const logger = lastLogger(); -// if (logger) { -// logger.debug(msg, { args }); -// } -// }; -// console.log = (msg: string, ...args: any[]) => { -// const logger = lastLogger(); -// if (logger) { -// logger.info(msg, { args }); -// } -// }; -// console.warn = (msg: string, ...args: any[]) => { -// const logger = lastLogger(); -// if (logger) { -// logger.warn(msg, { args }); -// } -// }; -// console.error = (msg: string, ...args: any[]) => { -// const logger = lastLogger(); -// if (logger) { -// logger.error(msg, { args }); -// } -// }; - -const testGlob = argv.glob || 'tests/**/*.ts'; -let currentFileName = null; - -const e2eRoot = path.join(__dirname, 'e2e'); -const allSetups = glob.sync(path.join(e2eRoot, 'setup/**/*.ts'), { nodir: true }) - .map(name => path.relative(e2eRoot, name)) - .sort(); -const allTests = glob.sync(path.join(e2eRoot, testGlob), { nodir: true, ignore: argv.ignore }) - .map(name => path.relative(e2eRoot, name)) - // TODO: UPDATE TESTS - // Replace windows slashes. - .map(name => name.replace(/\\/g, '/')) - .filter(name => !name.endsWith('/build-app-shell-with-schematic.ts')) - // IS this test still valid? \/ - .filter(name => !name.endsWith('/module-id.ts')) - // Do we want to support this? - .filter(name => !name.endsWith('different-file-format.ts')) - // Not sure what this test is meant to test, but with depedency changes it is not valid anymore. - .filter(name => !name.endsWith('loaders-resolution.ts')) - // NEW COMMAND - .filter(name => !name.includes('tests/commands/new/')) - // NEEDS devkit change - .filter(name => !name.endsWith('/existing-directory.ts')) - // ngtools/webpack is now in devkit and needs to be tested there - .filter(name => !name.endsWith('/packages/webpack/server-ng5.ts')) - .filter(name => !name.endsWith('/packages/webpack/test-ng5.ts')) - .filter(name => !name.endsWith('/packages/webpack/weird-ng5.ts')) - // Disabled on rc.0 due to needed sync with devkit for changes. - .filter(name => !name.endsWith('/service-worker.ts')) - .sort(); - -const shardId = ('shard' in argv) ? argv['shard'] : null; -const nbShards = (shardId === null ? 1 : argv['nb-shards']) || 2; -const tests = allTests - .filter(name => { - // Check for naming tests on command line. - if (argv._.length == 0) { - return true; - } - - return argv._.some(argName => { - return path.join(process.cwd(), argName) == path.join(__dirname, 'e2e', name) - || argName == name - || argName == name.replace(/\.ts$/, ''); - }); - }); - -// Remove tests that are not part of this shard. -const shardedTests = tests - .filter((name, i) => (shardId === null || (i % nbShards) == shardId)); -const testsToRun = allSetups.concat(shardedTests); - -console.log(testsToRun.join('\n')); -/** - * Load all the files from the e2e, filter and sort them and build a promise of their default - * export. - */ -if (testsToRun.length == allTests.length) { - console.log(`Running ${testsToRun.length} tests`); -} else { - console.log(`Running ${testsToRun.length} tests (${allTests.length + allSetups.length} total)`); -} - -setGlobalVariable('argv', argv); - -testsToRun.reduce((previous, relativeName, testIndex) => { - // Make sure this is a windows compatible path. - let absoluteName = path.join(e2eRoot, relativeName); - if (/^win/.test(process.platform)) { - absoluteName = absoluteName.replace(/\\/g, path.posix.sep); - } - - return previous.then(() => { - currentFileName = relativeName.replace(/\.ts$/, ''); - const start = +new Date(); - - const module = require(absoluteName); - const fn: (...args: any[]) => Promise | any = - (typeof module == 'function') ? module - : (typeof module.default == 'function') ? module.default - : () => { throw new Error('Invalid test module.'); }; - - let clean = true; - let previousDir = null; - return Promise.resolve() - .then(() => printHeader(currentFileName, testIndex)) - .then(() => previousDir = process.cwd()) - .then(() => logStack.push(lastLogger().createChild(currentFileName))) - .then(() => fn(() => clean = false)) - .then(() => logStack.pop(), (err: any) => { logStack.pop(); throw err; }) - .then(() => console.log('----')) - .then(() => { - // If we're not in a setup, change the directory back to where it was before the test. - // This allows tests to chdir without worrying about keeping the original directory. - if (allSetups.indexOf(relativeName) == -1 && previousDir) { - process.chdir(previousDir); - } - }) - .then(() => { - // Only clean after a real test, not a setup step. Also skip cleaning if the test - // requested an exception. - if (allSetups.indexOf(relativeName) == -1 && clean) { - logStack.push(new logging.NullLogger()); - return gitClean() - .then(() => logStack.pop(), (err: any) => { - logStack.pop(); - throw err; - }); - } - }) - .then(() => printFooter(currentFileName, start), - (err) => { - printFooter(currentFileName, start); - console.error(err); - throw err; - }); - }); -}, Promise.resolve()) - .then(() => { - console.log(green('Done.')); - process.exit(0); - }, - (err) => { - console.log('\n'); - console.error(red(`Test "${currentFileName}" failed...`)); - console.error(red(err.message)); - console.error(red(err.stack)); - - if (argv.debug) { - console.log(`Current Directory: ${process.cwd()}`); - console.log('Will loop forever while you debug... CTRL-C to quit.'); - - /* eslint-disable no-constant-condition */ - while (1) { - // That's right! - } - } - - process.exit(1); - }); - - -function encode(str) { - return str.replace(/[^A-Za-z\d\/]+/g, '-').replace(/\//g, '.').replace(/[\/-]$/, ''); -} - -function isTravis() { - return process.env['TRAVIS']; -} - -function printHeader(testName: string, testIndex: number) { - const text = `${testIndex + 1} of ${testsToRun.length}`; - const fullIndex = (testIndex < allSetups.length ? testIndex - : (testIndex - allSetups.length) * nbShards + shardId + allSetups.length) + 1; - const length = tests.length + allSetups.length; - const shard = shardId === null ? '' - : yellow(` [${shardId}:${nbShards}]` + bold(` (${fullIndex}/${length})`)); - console.log(green(`Running "${bold(blue(testName))}" (${bold(white(text))}${shard})...`)); - - if (isTravis()) { - console.log(`travis_fold:start:${encode(testName)}`); - } -} - -function printFooter(testName, startTime) { - if (isTravis()) { - console.log(`travis_fold:end:${encode(testName)}`); - } - - // Round to hundredth of a second. - const t = Math.round((Date.now() - startTime) / 10) / 100; - console.log(green('Last step took ') + bold(blue(t)) + green('s...')); - console.log(''); -} diff --git a/tests/legacy-cli/run_e2e.js b/tests/legacy-cli/run_e2e.js deleted file mode 100644 index fb004c88a042..000000000000 --- a/tests/legacy-cli/run_e2e.js +++ /dev/null @@ -1,3 +0,0 @@ -'use strict'; -require('../../lib/bootstrap-local'); -require('./e2e_runner.ts'); diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 000000000000..1068e992bfef --- /dev/null +++ b/tests/package.json @@ -0,0 +1,7 @@ +{ + "devDependencies": { + "@types/tar-stream": "3.1.5", + "@angular-devkit/schematics": "workspace:*", + "tar-stream": "3.2.2" + } +} diff --git a/tests/rollup.config.mjs b/tests/rollup.config.mjs new file mode 100644 index 000000000000..c48299094fef --- /dev/null +++ b/tests/rollup.config.mjs @@ -0,0 +1,45 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import glob from 'fast-glob'; + +const testFiles = [ + 'e2e_runner.js', + 'e2e/utils/test_process.js', + ...glob.sync('e2e/(initialize|setup|tests)/**/*.js'), +]; + +// Generate chunks to keep the original folder structure. +// Needed as we dynamically load these files. +const chunks = {}; +for (const file of testFiles) { + chunks[file.slice(0, -'.js'.length)] = file; +} + +export default { + input: chunks, + external: ['undici', 'puppeteer'], // This cannot be bundled as `node:sqlite` is experimental in node.js 22. Remove once this feature is no longer behind a flag + plugins: [ + nodeResolve({ + preferBuiltins: true, + browser: false, + }), + json(), + commonjs({ + // Test runner uses dynamic requires, and those are fine. + // Rollup should not try to process them. + ignoreDynamicRequires: true, + }), + ], + output: { + dir: './runner_bundled_out', + exports: 'auto', + }, +}; diff --git a/tests/schematics/update/packages/README.md b/tests/schematics/update/packages/README.md new file mode 100644 index 000000000000..83526f2cd776 --- /dev/null +++ b/tests/schematics/update/packages/README.md @@ -0,0 +1,14 @@ + +## Update Package Group packages + +* `update-package-group-1@1.0.0` -> `update-package-group2@^1` +* `update-package-group-2@1.0.0` -> `update-package-group1@1.0.0` +---- + +* `update-package-group-1@1.0.0` -> `update-package-group2@^1` +* `update-package-group-2@1.1.0` -> `update-package-group1@1.0.0` +---- + +* `update-package-group-1@1.2.0` -> `update-package-group2@^2` +* `update-package-group-2@2.0.0` -> `update-package-group1@^1` +---- diff --git a/tests/schematics/update/packages/update-migrations/v1_5.js b/tests/schematics/update/packages/update-migrations/v1_5.js index 7e79eda93d49..becf300fecdf 100644 --- a/tests/schematics/update/packages/update-migrations/v1_5.js +++ b/tests/schematics/update/packages/update-migrations/v1_5.js @@ -1,12 +1,13 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ -exports.default = function() { - return function(tree) { - tree.create('/version1_5', ''); - }; + +exports.default = function () { + return function (tree) { + tree.create('/version1_5', ''); + }; }; diff --git a/tests/schematics/update/packages/update-package-group-1/package.json b/tests/schematics/update/packages/update-package-group-1/package.json new file mode 100644 index 000000000000..58a55973dd36 --- /dev/null +++ b/tests/schematics/update/packages/update-package-group-1/package.json @@ -0,0 +1,11 @@ +{ + "name": "@angular-devkit-tests/update-package-group-1", + "version": "1.2.0", + "description": "Tests", + "ng-update": { + "packageGroup": { + "@angular-devkit-tests/update-package-group-1": "", + "@angular-devkit-tests/update-package-group-2": "^2" + } + } +} diff --git a/tests/schematics/update/packages/update-package-group-2/package.json b/tests/schematics/update/packages/update-package-group-2/package.json new file mode 100644 index 000000000000..39231fc6a190 --- /dev/null +++ b/tests/schematics/update/packages/update-package-group-2/package.json @@ -0,0 +1,11 @@ +{ + "name": "@angular-devkit-tests/update-package-group-2", + "version": "2.0.0", + "description": "Tests", + "ng-update": { + "packageGroup": { + "@angular-devkit-tests/update-package-group-1": "^1", + "@angular-devkit-tests/update-package-group-2": "" + } + } +} diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 000000000000..5070cc5b6927 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig-test.json", + "compilerOptions": { "paths": {} }, + "exclude": ["e2e/assets/**"] +} diff --git a/tests/verdaccio.yaml b/tests/verdaccio.yaml new file mode 100644 index 000000000000..a398f671b43e --- /dev/null +++ b/tests/verdaccio.yaml @@ -0,0 +1,51 @@ +storage: ./storage + +auth: + auth-memory: + users: {} +listen: localhost:${HTTP_PORT} +uplinks: + npmjs: + url: https://registry.npmjs.org/ + cache: false + maxage: 20m + max_fails: 32 + timeout: 60s + agent_options: + keepAlive: true + maxSockets: 32 + maxFreeSockets: 8 + +packages: + '@angular/{build,create,cli,pwa,ssr}': + access: $all + publish: $all + + '@angular-devkit/*': + access: $all + publish: $all + + '@ngtools/webpack': + access: $all + publish: $all + + '@schematics/{angular,schematics,update}': + access: $all + publish: $all + + '@*/*': + access: $all + proxy: npmjs + + '**': + access: $all + proxy: npmjs + +log: + type: stdout + format: pretty + level: warn + +# https://github.com/verdaccio/verdaccio/issues/301 +server: + keepAliveTimeout: 0 diff --git a/tests/verdaccio_auth.yaml b/tests/verdaccio_auth.yaml new file mode 100644 index 000000000000..1cb53f5d8b5f --- /dev/null +++ b/tests/verdaccio_auth.yaml @@ -0,0 +1,33 @@ +storage: ./storage +auth: + auth-memory: + users: + testing: + name: testing + password: s3cret +listen: localhost:${HTTPS_PORT} +uplinks: + local: + url: http://localhost:${HTTP_PORT} + cache: false + maxage: 20m + max_fails: 32 + timeout: 60s + agent_options: + keepAlive: true + maxSockets: 32 + maxFreeSockets: 8 + +packages: + '**': + access: $authenticated + proxy: local + +log: + type: stdout + format: pretty + level: warn + +# https://github.com/verdaccio/verdaccio/issues/301 +server: + keepAliveTimeout: 0 diff --git a/third_party/github.com/bazelbuild/bazel/src/main/java/com/google/devtools/build/lib/buildeventstream/proto/BUILD b/third_party/github.com/bazelbuild/bazel/src/main/java/com/google/devtools/build/lib/buildeventstream/proto/BUILD deleted file mode 100644 index 531b4325ee17..000000000000 --- a/third_party/github.com/bazelbuild/bazel/src/main/java/com/google/devtools/build/lib/buildeventstream/proto/BUILD +++ /dev/null @@ -1,9 +0,0 @@ -package(default_visibility = ["//:__subpackages__"]) - -licenses(["notice"]) # Apache 2.0 - -proto_library( - name = "proto", - srcs = glob(["*.proto"]), - deps = ["//third_party/github.com/bazelbuild/bazel/src/main/protobuf"], -) diff --git a/third_party/github.com/bazelbuild/bazel/src/main/java/com/google/devtools/build/lib/buildeventstream/proto/build_event_stream.proto b/third_party/github.com/bazelbuild/bazel/src/main/java/com/google/devtools/build/lib/buildeventstream/proto/build_event_stream.proto deleted file mode 100644 index b8556bc9b30f..000000000000 --- a/third_party/github.com/bazelbuild/bazel/src/main/java/com/google/devtools/build/lib/buildeventstream/proto/build_event_stream.proto +++ /dev/null @@ -1,700 +0,0 @@ -// Copyright 2016 The Bazel Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -syntax = "proto3"; - -package build_event_stream; - -option java_package = "com.google.devtools.build.lib.buildeventstream"; -option java_outer_classname = "BuildEventStreamProtos"; - -// LOCAL MOD: change import path -import "third_party/github.com/bazelbuild/bazel/src/main/protobuf/invocation_policy.proto"; -import "third_party/github.com/bazelbuild/bazel/src/main/protobuf/command_line.proto"; - -// Identifier for a build event. It is deliberately structured to also provide -// information about which build target etc the event is related to. -// -// Events are chained via the event id as follows: each event has an id and a -// set of ids of children events such that apart from the initial event each -// event has an id that is mentioned as child id in an earlier event and a build -// invocation is complete if and only if all direct and indirect children of the -// initial event have been posted. -message BuildEventId { - // Generic identifier for a build event. This is the default type of - // BuildEventId, but should not be used outside testing; nevertheless, - // tools should handle build events with this kind of id gracefully. - message UnknownBuildEventId { - string details = 1; - } - - // Identifier of an event reporting progress. Those events are also used to - // chain in events that come early. - message ProgressId { - // Unique identifier. No assumption should be made about how the ids are - // assigned; the only meaningful operation on this field is test for - // equality. - int32 opaque_count = 1; - } - - // Identifier of an event indicating the beginning of a build; this will - // normally be the first event. - message BuildStartedId { - } - - // Identifier on an event indicating the original commandline received by - // the bazel server. - message UnstructuredCommandLineId { - } - - // Identifier on an event describing the commandline received by Bazel. - message StructuredCommandLineId { - // A title for this command line value, as there may be multiple. - // For example, a single invocation may wish to report both the literal and - // canonical command lines, and this label would be used to differentiate - // between both versions. - string command_line_label = 1; - } - - // Identifier of an event indicating the workspace status. - message WorkspaceStatusId { - } - - // Identifier on an event reporting on the options included in the command - // line, both explicitly and implicitly. - message OptionsParsedId { - } - - // Identifier of an event reporting that an external resource was fetched - // from. - message FetchId { - // The external resource that was fetched from. - string url = 1; - } - - // Identifier of an event indicating that a target pattern has been expanded - // further. - // Messages of this shape are also used to describe parts of a pattern that - // have been skipped for some reason, if the actual expansion was still - // carried out (e.g., if keep_going is set). In this case, the - // pattern_skipped choice in the id field is to be made. - message PatternExpandedId { - repeated string pattern = 1; - } - - // Identifier of an event indicating that a target has been expanded by - // identifying for which configurations it should be build. - message TargetConfiguredId { - string label = 1; - - // If not empty, the id refers to the expansion of the target for a given - // aspect. - string aspect = 2; - } - - // Identifier of an event introducing a named set of files (usually artifacts) - // to be referred to in later messages. - message NamedSetOfFilesId { - // Identifier of the file set; this is an opaque string valid only for the - // particular instance of the event stream. - string id = 1; - } - - // Identifier of an event introducing a configuration. - message ConfigurationId { - // Identifier of the configuration; users of the protocol should not make - // any assumptions about it having any structure, or equality of the - // identifier between different streams. - string id = 1; - } - - // Identifier of an event indicating that a target was built completely; this - // does not include running the test if the target is a test target. - message TargetCompletedId { - string label = 1; - - // The configuration for which the target was built. - ConfigurationId configuration = 3; - - // If not empty, the id refers to the completion of the target for a given - // aspect. - string aspect = 2; - } - - // Identifier of an event reporting that an action was completed (not all - // actions are reported, only the ones that can be considered important; - // this includes all failed actions). - message ActionCompletedId { - string primary_output = 1; - // Optional, the label of the owner of the action, for reference. - string label = 2; - // Optional, the id of the configuration of the action owner. - ConfigurationId configuration = 3; - } - - // Identifier of an event reporting an event associated with an unconfigured - // label. Usually, this indicates a failure due to a missing input file. In - // any case, it will report some form of error (i.e., the payload will be an - // Aborted event); there are no regular events using this identifier. The - // purpose of those events is to serve as the root cause of a failed target. - message UnconfiguredLabelId { - string label = 1; - } - - // Identifier of an event reporting an event associated with a configured - // label, usually a visibility error. In any case, an event with such an - // id will always report some form of error (i.e., the payload will be an - // Aborted event); there are no regular events using this identifier. - message ConfiguredLabelId { - string label = 1; - ConfigurationId configuration = 2; - } - - // Identifier of an event reporting on an individual test run. The label - // identifies the test that is reported about, the remaining fields are - // in such a way as to uniquely identify the action within a build. In fact, - // attempts for the same test, run, shard triple are counted sequentially, - // starting with 1. - message TestResultId { - string label = 1; - ConfigurationId configuration = 5; - int32 run = 2; - int32 shard = 3; - int32 attempt = 4; - } - - // Identifier of an event reporting the summary of a test. - message TestSummaryId { - string label = 1; - ConfigurationId configuration = 2; - } - - // Identifier of the BuildFinished event, indicating the end of a build. - message BuildFinishedId { - } - - // Identifier of an event providing additional logs/statistics after - // completion of the build. - message BuildToolLogsId { - } - - // Identifier of an event providing build metrics after completion - // of the build. - message BuildMetricsId { - } - - oneof id { - UnknownBuildEventId unknown = 1; - ProgressId progress = 2; - BuildStartedId started = 3; - UnstructuredCommandLineId unstructured_command_line = 11; - StructuredCommandLineId structured_command_line = 18; - WorkspaceStatusId workspace_status = 14; - OptionsParsedId options_parsed = 12; - FetchId fetch = 17; - ConfigurationId configuration = 15; - TargetConfiguredId target_configured = 16; - PatternExpandedId pattern = 4; - PatternExpandedId pattern_skipped = 10; - NamedSetOfFilesId named_set = 13; - TargetCompletedId target_completed = 5; - ActionCompletedId action_completed = 6; - UnconfiguredLabelId unconfigured_label = 19; - ConfiguredLabelId configured_label = 21; - TestResultId test_result = 8; - TestSummaryId test_summary = 7; - BuildFinishedId build_finished = 9; - BuildToolLogsId build_tool_logs = 20; - BuildMetricsId build_metrics = 22; - } -} - -// Payload of an event summarizing the progress of the build so far. Those -// events are also used to be parents of events where the more logical parent -// event cannot be posted yet as the needed information is not yet complete. -message Progress { - // The next chunk of stdout that bazel produced since the last progress event - // or the beginning of the build. - string stdout = 1; - - // The next chunk of stderr that bazel produced since the last progress event - // or the beginning of the build. - string stderr = 2; -} - -// Payload of an event indicating that an expected event will not come, as -// the build is aborted prematurely for some reason. -message Aborted { - enum AbortReason { - UNKNOWN = 0; - - // The user requested the build to be aborted (e.g., by hitting Ctl-C). - USER_INTERRUPTED = 1; - - // The user requested that no analysis be performed. - NO_ANALYZE = 8; - - // The user requested that no build be carried out. - NO_BUILD = 9; - - // The build or target was aborted as a timeout was exceeded. - TIME_OUT = 2; - - // The build or target was aborted as some remote environment (e.g., for - // remote execution of actions) was not available in the expected way. - REMOTE_ENVIRONMENT_FAILURE = 3; - - // Failure due to reasons entirely internal to the build tool, e.g., - // running out of memory. - INTERNAL = 4; - - // A Failure occurred in the loading phase of a target. - LOADING_FAILURE = 5; - - // A Failure occurred in the analysis phase of a target. - ANALYSIS_FAILURE = 6; - - // Target build was skipped (e.g. due to incompatible CPU constraints). - SKIPPED = 7; - } - AbortReason reason = 1; - - // A human readable description with more details about there reason, where - // available and useful. - string description = 2; -} - -// Payload of an event indicating the beginning of a new build. Usually, events -// of those type start a new build-event stream. The target pattern requested -// to be build is contained in one of the announced child events; it is an -// invariant that precisely one of the announced child events has a non-empty -// target pattern. -message BuildStarted { - string uuid = 1; - - // Start of the build in ms since the epoch. - // TODO(buchgr): Use google.protobuf.TimeStamp once bazel's protoc supports - // it. - int64 start_time_millis = 2; - - // Version of the build tool that is running. - string build_tool_version = 3; - - // A human-readable description of all the non-default option settings - string options_description = 4; - - // The name of the command that the user invoked. - string command = 5; - - // The working directory from which the build tool was invoked. - string working_directory = 6; - - // The directory of the workspace. - string workspace_directory = 7; - - // The process ID of the Bazel server. - int64 server_pid = 8; -} - -// Payload of an event reporting the command-line of the invocation as -// originally received by the server. Note that this is not the command-line -// given by the user, as the client adds information about the invocation, -// like name and relevant entries of rc-files and client environment variables. -// However, it does contain enough information to reproduce the build -// invocation. -message UnstructuredCommandLine { - repeated string args = 1; -} - -// Payload of an event reporting on the parsed options, grouped in various ways. -message OptionsParsed { - repeated string startup_options = 1; - repeated string explicit_startup_options = 2; - repeated string cmd_line = 3; - repeated string explicit_cmd_line = 4; - blaze.invocation_policy.InvocationPolicy invocation_policy = 5; - string tool_tag = 6; -} - -// Payload of an event indicating that an external resource was fetched. This -// event will only occur in streams where an actual fetch happened, not in ones -// where a cached copy of the entity to be fetched was used. -message Fetch { - bool success = 1; -} - -// Payload of an event reporting the workspace status. Key-value pairs can be -// provided by specifying the workspace_status_command to an executable that -// returns one key-value pair per line of output (key and value separated by a -// space). -message WorkspaceStatus { - message Item { - string key = 1; - string value = 2; - } - repeated Item item = 1; -} - -// Payload of an event reporting details of a given configuration. -message Configuration { - string mnemonic = 1; - string platform_name = 2; - string cpu = 3; - map make_variable = 4; -} - -// Payload of the event indicating the expansion of a target pattern. -// The main information is in the chaining part: the id will contain the -// target pattern that was expanded and the children id will contain the -// target or target pattern it was expanded to. -message PatternExpanded { -} - -// Enumeration type characterizing the size of a test, as specified by the -// test rule. -enum TestSize { - UNKNOWN = 0; - SMALL = 1; - MEDIUM = 2; - LARGE = 3; - ENORMOUS = 4; -} - -// Payload of the event indicating that the configurations for a target have -// been identified. As with pattern expansion the main information is in the -// chaining part: the id will contain the target that was configured and the -// children id will contain the configured targets it was configured to. -message TargetConfigured { - // The kind of target (e.g., e.g. "cc_library rule", "source file", - // "generated file") where the completion is reported. - string target_kind = 1; - - // The size of the test, if the target is a test target. Unset otherwise. - TestSize test_size = 2; - - // List of all tags associated with this target (for all possible - // configurations). - repeated string tag = 3; -} - -message File { - // identifier indicating the nature of the file (e.g., "stdout", "stderr") - string name = 1; - - oneof file { - // A location where the contents of the file can be found. The string is - // encoded according to RFC2396. - string uri = 2; - // The contents of the file, if they are guaranteed to be short. - bytes contents = 3; - } -} - -// Payload of a message to describe a set of files, usually build artifacts, to -// be referred to later by their name. In this way, files that occur identically -// as outputs of several targets have to be named only once. -message NamedSetOfFiles { - // Files that belong to this named set of files. - repeated File files = 1; - - // Other named sets whose members also belong to this set. - repeated BuildEventId.NamedSetOfFilesId file_sets = 2; -} - -// Payload of the event indicating the completion of an action. The main purpose -// of posting those events is to provide details on the root cause for a target -// failing; however, consumers of the build-event protocol must not assume -// that only failed actions are posted. -message ActionExecuted { - bool success = 1; - - // The mnemonic of the action that was executed - string type = 8; - - // The exit code of the action, if it is available. - int32 exit_code = 2; - - // Location where to find the standard output of the action - // (e.g., a file path). - File stdout = 3; - - // Location where to find the standard error of the action - // (e.g., a file path). - File stderr = 4; - - // Deprecated. This field is now present on ActionCompletedId. - string label = 5 [deprecated = true]; - - // Deprecated. This field is now present on ActionCompletedId. - BuildEventId.ConfigurationId configuration = 7 [deprecated = true]; - - // Primary output; only provided for successful actions. - File primary_output = 6; - - // The command-line of the action, if the action is a command. - repeated string command_line = 9; -} - -// Collection of all output files belonging to that output group. -message OutputGroup { - // Ids of fields that have been removed. - reserved 2; - - // Name of the output group - string name = 1; - - // List of file sets that belong to this output group as well. - repeated BuildEventId.NamedSetOfFilesId file_sets = 3; -} - -// Payload of the event indicating the completion of a target. The target is -// specified in the id. If the target failed the root causes are provided as -// children events. -message TargetComplete { - bool success = 1; - - // The kind of target (e.g., e.g. "cc_library rule", "source file", - // "generated file") where the completion is reported. - // Deprecated: use the target_kind field in TargetConfigured instead. - string target_kind = 5 [deprecated = true]; - - // The size of the test, if the target is a test target. Unset otherwise. - // Deprecated: use the test_size field in TargetConfigured instead. - TestSize test_size = 6 [deprecated = true]; - - // The output files are arranged by their output group. If an output file - // is part of multiple output groups, it appears once in each output - // group. - repeated OutputGroup output_group = 2; - - // Temporarily, also report the important outputs directly. This is only to - // allow existing clients help transition to the deduplicated representation; - // new clients should not use it. - repeated File important_output = 4 [deprecated = true]; - - // List of tags associated with this configured target. - repeated string tag = 3; - - // The timeout specified for test actions under this configured target. - int64 test_timeout_seconds = 7; -} - -enum TestStatus { - NO_STATUS = 0; - PASSED = 1; - FLAKY = 2; - TIMEOUT = 3; - FAILED = 4; - INCOMPLETE = 5; - REMOTE_FAILURE = 6; - FAILED_TO_BUILD = 7; - TOOL_HALTED_BEFORE_TESTING = 8; -}; - -// Payload on events reporting about individual test action. -message TestResult { - reserved 1; - - // The status of this test. - TestStatus status = 5; - - // Additional details about the status of the test. This is intended for - // user display and must not be parsed. - string status_details = 9; - - // True, if the reported attempt is taken from the tool's local cache. - bool cached_locally = 4; - - // Time in milliseconds since the epoch at which the test attempt was started. - // Note: for cached test results, this is time can be before the start of the - // build. - int64 test_attempt_start_millis_epoch = 6; - - // Time the test took to run. For locally cached results, this is the time - // the cached invocation took when it was invoked. - int64 test_attempt_duration_millis = 3; - - // Files (logs, test.xml, undeclared outputs, etc) generated by that test - // action. - repeated File test_action_output = 2; - - // Warnings generated by that test action. - repeated string warning = 7; - - // Message providing optional meta data on the execution of the test action, - // if available. - message ExecutionInfo { - // Deprecated, use TargetComplete.test_timeout_seconds instead. - int32 timeout_seconds = 1 [deprecated = true]; - - // Name of the strategy to execute this test action (e.g., "local", - // "remote") - string strategy = 2; - - // True, if the reported attempt was a cache hit in a remote cache. - bool cached_remotely = 6; - - // The exit code of the test action. - int32 exit_code = 7; - - // The hostname of the machine where the test action was executed (in case - // of remote execution), if known. - string hostname = 3; - - // Represents a hierarchical timing breakdown of an activity. - // The top level time should be the total time of the activity. - // Invariant: time_millis >= sum of time_millis of all direct children. - message TimingBreakdown { - repeated TimingBreakdown child = 1; - string name = 2; - int64 time_millis = 3; - } - TimingBreakdown timing_breakdown = 4; - - message ResourceUsage { - string name = 1; - int64 value = 2; - } - repeated ResourceUsage resource_usage = 5; - } - ExecutionInfo execution_info = 8; -} - -// Payload of the event summarizing a test. -// TODO(aehlig): extend event with additional information as soon as we known -// which additional information we need for test summaries. -message TestSummary { - // Wrapper around BlazeTestStatus to support importing that enum to proto3. - // Overall status of test, accumulated over all runs, shards, and attempts. - TestStatus overall_status = 5; - - // Total number of runs - int32 total_run_count = 1; - - // Path to logs of passed runs. - repeated File passed = 3; - - // Path to logs of failed runs; - repeated File failed = 4; - - // Total number of cached test actions - int32 total_num_cached = 6; -} - -// Event indicating the end of a build. -message BuildFinished { - // Exit code of a build. The possible values correspond to the predefined - // codes in bazel's lib.ExitCode class, as well as any custom exit code a - // module might define. The predefined exit codes are subject to change (but - // rarely do) and are not part of the public API. - // - // A build was successful iff ExitCode.code equals 0. - message ExitCode { - // The name of the exit code. - string name = 1; - - // The exit code. - int32 code = 2; - } - - // If the build succeeded or failed. - bool overall_success = 1 [deprecated = true]; - - // The overall status of the build. A build was successful iff - // ExitCode.code equals 0. - ExitCode exit_code = 3; - - // Time in milliseconds since the epoch. - // TODO(buchgr): Use google.protobuf.Timestamp once bazel's protoc supports - // it. - int64 finish_time_millis = 2; -} - -message BuildMetrics { - message ActionSummary { - // The total number of actions created and registered during the build. - // This includes unused actions that were constructed but - // not executed during this build. - int64 actions_created = 1; - - // The total number of actions executed during the build. - // This includes any remote cache hits, but excludes - // local action cache hits. - int64 actions_executed = 2; - } - ActionSummary action_summary = 1; - - message MemoryMetrics { - // Size of the JVM heap post build in bytes. This is only collected if - // --bep_publish_used_heap_size_post_build is set, - // since it forces a full GC. - int64 used_heap_size_post_build = 1; - } - MemoryMetrics memory_metrics = 2; - - message TargetMetrics { - // Number of targets loaded during this build. - int64 targets_loaded = 1; - - // Number of targets configured during this build. This can - // be greater than targets_loaded if the same target is configured - // multiple times. - int64 targets_configured = 2; - } - TargetMetrics target_metrics = 3; - - message PackageMetrics { - // Number of BUILD files (aka packages) loaded during this build. - int64 packages_loaded = 1; - } - PackageMetrics package_metrics = 4; -} - -// Event providing additional statistics/logs after completion of the build. -message BuildToolLogs { - repeated File log = 1; -} - -// Message describing a build event. Events will have an identifier that -// is unique within a given build invocation; they also announce follow-up -// events as children. More details, which are specific to the kind of event -// that is observed, is provided in the payload. More options for the payload -// might be added in the future. -message BuildEvent { - reserved 11, 19; - BuildEventId id = 1; - repeated BuildEventId children = 2; - bool last_message = 20; - oneof payload { - Progress progress = 3; - Aborted aborted = 4; - BuildStarted started = 5; - UnstructuredCommandLine unstructured_command_line = 12; - command_line.CommandLine structured_command_line = 22; - OptionsParsed options_parsed = 13; - WorkspaceStatus workspace_status = 16; - Fetch fetch = 21; - Configuration configuration = 17; - PatternExpanded expanded = 6; - TargetConfigured configured = 18; - ActionExecuted action = 7; - NamedSetOfFiles named_set_of_files = 15; - TargetComplete completed = 8; - TestResult test_result = 10; - TestSummary test_summary = 9; - BuildFinished finished = 14; - BuildToolLogs build_tool_logs = 23; - BuildMetrics build_metrics = 24; - }; -} diff --git a/third_party/github.com/bazelbuild/bazel/src/main/protobuf/BUILD b/third_party/github.com/bazelbuild/bazel/src/main/protobuf/BUILD deleted file mode 100644 index ea73d6c2a1f4..000000000000 --- a/third_party/github.com/bazelbuild/bazel/src/main/protobuf/BUILD +++ /dev/null @@ -1,8 +0,0 @@ -package(default_visibility = ["//:__subpackages__"]) - -licenses(["notice"]) # Apache 2.0 - -proto_library( - name = "protobuf", - srcs = glob(["*.proto"]), -) diff --git a/third_party/github.com/bazelbuild/bazel/src/main/protobuf/command_line.proto b/third_party/github.com/bazelbuild/bazel/src/main/protobuf/command_line.proto deleted file mode 100644 index fd09cd7c0aed..000000000000 --- a/third_party/github.com/bazelbuild/bazel/src/main/protobuf/command_line.proto +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright 2017 The Bazel Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -syntax = "proto3"; -package command_line; - -// option java_api_version = 2; -option java_package = "com.google.devtools.build.lib.runtime.proto"; - -// LOCAL MOD: change import path -import "third_party/github.com/bazelbuild/bazel/src/main/protobuf/option_filters.proto"; - -// Representation of a Bazel command line. -message CommandLine { - // A title for this command line value, to differentiate it from others. - // In particular, a single invocation may wish to report both the literal and - // canonical command lines, and this label would be used to differentiate - // between both versions. This is a string for flexibility. - string command_line_label = 1; - - // A Bazel command line is made of distinct parts. For example, - // `bazel --nomaster_bazelrc test --nocache_test_results //foo:aTest` - // has the executable "bazel", a startup flag, a command "test", a command - // flag, and a test target. There could be many more flags and targets, or - // none (`bazel info` for example), but the basic structure is there. The - // command line should be broken down into these logical sections here. - repeated CommandLineSection sections = 2; -} - -// A section of the Bazel command line. -message CommandLineSection { - // The name of this section, such as "startup_option" or "command". - string section_label = 1; - - oneof section_type { - // Sections with non-options, such as the list of targets or the command, - // should use simple string chunks. - ChunkList chunk_list = 2; - - // Startup and command options are lists of options and belong here. - OptionList option_list = 3; - } -} - -// Wrapper to allow a list of strings in the "oneof" section_type. -message ChunkList { - repeated string chunk = 1; -} - -// Wrapper to allow a list of options in the "oneof" section_type. -message OptionList { - repeated Option option = 1; -} - -// A single command line option. -// -// This represents the option itself, but does not take into account the type of -// option or how the parser interpreted it. If this option is part of a command -// line that represents the actual input that Bazel received, it would, for -// example, include expansion flags as they are. However, if this option -// represents the canonical form of the command line, with the values as Bazel -// understands them, then the expansion flag, which has no value, would not -// appear, and the flags it expands to would. -message Option { - // How the option looks with the option and its value combined. Depending on - // the purpose of this command line report, this could be the canonical - // form, or the way that the flag was set. - // - // Some examples: this might be `--foo=bar` form, or `--foo bar` with a space; - // for boolean flags, `--nobaz` is accepted on top of `--baz=false` and other - // negating values, or for a positive value, the unqualified `--baz` form - // is also accepted. This could also be a short `-b`, if the flag has an - // abbreviated form. - string combined_form = 1; - - // The canonical name of the option, without the preceding dashes. - string option_name = 2; - - // The value of the flag, or unset for flags that do not take values. - // Especially for boolean flags, this should be in canonical form, the - // combined_form field above gives room for showing the flag as it was set - // if that is preferred. - string option_value = 3; - - // This flag's tagged effects. See OptionEffectTag's java documentation for - // details. - repeated options.OptionEffectTag effect_tags = 4; - - // Metadata about the flag. See OptionMetadataTag's java documentation for - // details. - repeated options.OptionMetadataTag metadata_tags = 5; -} diff --git a/third_party/github.com/bazelbuild/bazel/src/main/protobuf/invocation_policy.proto b/third_party/github.com/bazelbuild/bazel/src/main/protobuf/invocation_policy.proto deleted file mode 100644 index 53572ac12011..000000000000 --- a/third_party/github.com/bazelbuild/bazel/src/main/protobuf/invocation_policy.proto +++ /dev/null @@ -1,190 +0,0 @@ -// Copyright 2015 The Bazel Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -syntax = "proto2"; -package blaze.invocation_policy; - -// option java_api_version = 2; -option java_package = "com.google.devtools.build.lib.runtime.proto"; - -// The --invocation_policy flag takes a base64-encoded binary-serialized or text -// formatted InvocationPolicy message. -message InvocationPolicy { - // Order matters. - // After expanding policies on expansion flags or flags with implicit - // requirements, only the final policy on a specific flag will be enforced - // onto the user's command line. - repeated FlagPolicy flag_policies = 1; -} - -// A policy for controlling the value of a flag. -message FlagPolicy { - // The name of the flag to enforce this policy on. - // - // Note that this should be the full name of the flag, not the abbreviated - // name of the flag. If the user specifies the abbreviated name of a flag, - // that flag will be matched using its full name. - // - // The "no" prefix will not be parsed, so for boolean flags, use - // the flag's full name and explicitly set it to true or false. - optional string flag_name = 1; - - // If set, this flag policy is applied only if one of the given commands or a - // command that inherits from one of the given commands is being run. For - // instance, if "build" is one of the commands here, then this policy will - // apply to any command that inherits from build, such as info, coverage, or - // test. If empty, this flag policy is applied for all commands. This allows - // the policy setter to add all policies to the proto without having to - // determine which Bazel command the user is actually running. Additionally, - // Bazel allows multiple flags to be defined by the same name, and the - // specific flag definition is determined by the command. - repeated string commands = 2; - - oneof operation { - SetValue set_value = 3; - UseDefault use_default = 4; - DisallowValues disallow_values = 5; - AllowValues allow_values = 6; - } -} - -message SetValue { - // Use this value for the specified flag, overriding any default or user-set - // value (unless append is set to true for repeatable flags). - // - // This field is repeated for repeatable flags. It is an error to set - // multiple values for a flag that is not actually a repeatable flag. - // This requires at least 1 value, if even the empty string. - // - // If the flag allows multiple values, all of its values are replaced with the - // value or values from the policy (i.e., no diffing or merging is performed), - // unless the append field (see below) is set to true. - // - // Note that some flags are tricky. For example, some flags look like boolean - // flags, but are actually Void expansion flags that expand into other flags. - // The Bazel flag parser will accept "--void_flag=false", but because - // the flag is Void, the "=false" is ignored. It can get even trickier, like - // "--novoid_flag" which is also an expansion flag with the type Void whose - // name is explicitly "novoid_flag" and which expands into other flags that - // are the opposite of "--void_flag". For expansion flags, it's best to - // explicitly override the flags they expand into. - // - // Other flags may be differently tricky: A flag could have a converter that - // converts some string to a list of values, but that flag may not itself have - // allowMultiple set to true. - // - // An example is "--test_tag_filters": this flag sets its converter to - // CommaSeparatedOptionListConverter, but does not set allowMultiple to true. - // So "--test_tag_filters=foo,bar" results in ["foo", "bar"], however - // "--test_tag_filters=foo --test_tag_filters=bar" results in just ["bar"] - // since the 2nd value overrides the 1st. - // - // Similarly, "--test_tag_filters=foo,bar --test_tag_filters=baz,qux" results - // in ["baz", "qux"]. For flags like these, the policy should specify - // "foo,bar" instead of separately specifying "foo" and "bar" so that the - // converter is appropriately invoked. - // - // Note that the opposite is not necessarily - // true: for a flag that specifies allowMultiple=true, "--flag=foo,bar" - // may fail to parse or result in an unexpected value. - repeated string flag_value = 1; - - // Whether to allow this policy to be overridden by user-specified values. - // When set, if the user specified a value for this flag, use the value - // from the user, otherwise use the value specified in this policy. - optional bool overridable = 2; - - // If true, and if the flag named in the policy is a repeatable flag, then - // the values listed in flag_value do not replace all the user-set or default - // values of the flag, but instead append to them. If the flag is not - // repeatable, then this has no effect. - optional bool append = 3; -} - -message UseDefault { - // Use the default value of the flag, as defined by Bazel (or equivalently, do - // not allow the user to set this flag). - // - // Note on implementation: UseDefault sets the default by clearing the flag, - // so that when the value is requested and no flag is found, the flag parser - // returns the default. This is mostly relevant for expansion flags: it will - // erase user values in *all* flags that the expansion flag expands to. Only - // use this on expansion flags if this is acceptable behavior. Since the last - // policy wins, later policies on this same flag will still remove the - // expanded UseDefault, so there is a way around, but it's really best not to - // use this on expansion flags at all. -} - -message DisallowValues { - // Obsolete new_default_value field. - reserved 2; - - // It is an error for the user to use any of these values (that is, the Bazel - // command will fail), unless new_value or use_default is set. - // - // For repeatable flags, if any one of the values in the flag matches a value - // in the list of disallowed values, an error is thrown. - // - // Care must be taken for flags with complicated converters. For example, - // it's possible for a repeated flag to be of type List>, so that - // "--foo=a,b --foo=c,d" results in foo=[["a","b"], ["c", "d"]]. In this case, - // it is not possible to disallow just "b", nor will ["b", "a"] match, nor - // will ["b", "c"] (but ["a", "b"] will still match). - repeated string disallowed_values = 1; - - oneof replacement_value { - // If set and if the value of the flag is disallowed (including the default - // value of the flag if the user doesn't specify a value), use this value as - // the value of the flag instead of raising an error. This does not apply to - // repeatable flags and is ignored if the flag is a repeatable flag. - string new_value = 3; - - // If set and if the value of the flag is disallowed, use the default value - // of the flag instead of raising an error. Unlike new_value, this works for - // repeatable flags, but note that the default value for repeatable flags is - // always empty. - // - // Note that it is an error to disallow the default value of the flag and - // to set use_default, unless the flag is a repeatable flag where the - // default value is always the empty list. - UseDefault use_default = 4; - } -} - -message AllowValues { - // Obsolete new_default_value field. - reserved 2; - - // It is an error for the user to use any value not in this list, unless - // new_value or use_default is set. - repeated string allowed_values = 1; - - oneof replacement_value { - // If set and if the value of the flag is disallowed (including the default - // value of the flag if the user doesn't specify a value), use this value as - // the value of the flag instead of raising an error. This does not apply to - // repeatable flags and is ignored if the flag is a repeatable flag. - string new_value = 3; - - // If set and if the value of the flag is disallowed, use the default value - // of the flag instead of raising an error. Unlike new_value, this works for - // repeatable flags, but note that the default value for repeatable flags is - // always empty. - // - // Note that it is an error to disallow the default value of the flag and - // to set use_default, unless the flag is a repeatable flag where the - // default value is always the empty list. - UseDefault use_default = 4; - } -} diff --git a/third_party/github.com/bazelbuild/bazel/src/main/protobuf/option_filters.proto b/third_party/github.com/bazelbuild/bazel/src/main/protobuf/option_filters.proto deleted file mode 100644 index 782620d05371..000000000000 --- a/third_party/github.com/bazelbuild/bazel/src/main/protobuf/option_filters.proto +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2017 The Bazel Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -syntax = "proto3"; -package options; - -// option java_api_version = 2; -option java_package = "com.google.devtools.common.options.proto"; - -// IMPORTANT NOTE: These two enums must be kept in sync with their Java -// equivalents in src/main/java/com/google/devtools/common/options. -// Changing this proto has specific compatibility requirements, please see the -// Java documentation for details. - -// Docs in java enum. -enum OptionEffectTag { - // This option's effect or intent is unknown. - UNKNOWN = 0; - - // This flag has literally no effect. - NO_OP = 1; - - LOSES_INCREMENTAL_STATE = 2; - CHANGES_INPUTS = 3; - AFFECTS_OUTPUTS = 4; - BUILD_FILE_SEMANTICS = 5; - BAZEL_INTERNAL_CONFIGURATION = 6; - LOADING_AND_ANALYSIS = 7; - EXECUTION = 8; - HOST_MACHINE_RESOURCE_OPTIMIZATIONS = 9; - EAGERNESS_TO_EXIT = 10; - BAZEL_MONITORING = 11; - TERMINAL_OUTPUT = 12; - ACTION_COMMAND_LINES = 13; - TEST_RUNNER = 14; -} - -// Docs in java enum. -enum OptionMetadataTag { - EXPERIMENTAL = 0; - INCOMPATIBLE_CHANGE = 1; - DEPRECATED = 2; - HIDDEN = 3; - INTERNAL = 4; - TRIGGERED_BY_ALL_INCOMPATIBLE_CHANGES = 5; -} diff --git a/tools/BUILD b/tools/BUILD deleted file mode 100644 index 517fbaa9db28..000000000000 --- a/tools/BUILD +++ /dev/null @@ -1,24 +0,0 @@ -# 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 -# @external_begin -package(default_visibility = ["//visibility:public"]) - -load("@build_bazel_rules_nodejs//:defs.bzl", "nodejs_binary") - -nodejs_binary( - name = 'quicktype_runner', - data = [ - ":quicktype_runner.js", - ], - node_modules = "//:quicktype_node_modules", - entry_point = "angular_devkit/tools/quicktype_runner.js", - templated_args = [ - # Needed so that node doesn't walk back to the source directory. - # From there, the relative imports would point to .ts files. - "--node_options=--preserve-symlinks", - ], - install_source_map_support = False, -) -# @external_end diff --git a/tools/BUILD.bazel b/tools/BUILD.bazel new file mode 100644 index 000000000000..4804a9ee59a6 --- /dev/null +++ b/tools/BUILD.bazel @@ -0,0 +1,32 @@ +load("//tools:defaults.bzl", "js_binary") + +package(default_visibility = ["//visibility:public"]) + +platform( + name = "windows_x64", + constraint_values = [ + "@platforms//os:windows", + "@platforms//cpu:x86_64", + ], +) + +exports_files([ + "package_json_release_filter.jq", +]) + +js_binary( + name = "ng_cli_schema", + data = [ + "ng_cli_schema_generator.js", + ], + entry_point = "ng_cli_schema_generator.js", +) + +js_binary( + name = "quicktype_runner", + data = [ + "quicktype_runner.js", + "//:node_modules/quicktype-core", + ], + entry_point = "quicktype_runner.js", +) diff --git a/tools/bazel.rc b/tools/bazel.rc deleted file mode 100644 index 722747468c4f..000000000000 --- a/tools/bazel.rc +++ /dev/null @@ -1,8 +0,0 @@ -# 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 - -# Performance: avoid stat'ing input files -build --watchfs - -test --test_output=errors diff --git a/packages/schematics/angular/component/files/__name@dasherize@if-flat__/__name@dasherize__.component.__styleext__ b/tools/bazel/BUILD.bazel similarity index 100% rename from packages/schematics/angular/component/files/__name@dasherize@if-flat__/__name@dasherize__.component.__styleext__ rename to tools/bazel/BUILD.bazel diff --git a/tools/bazel/npm_package.bzl b/tools/bazel/npm_package.bzl new file mode 100644 index 000000000000..3e2843f37946 --- /dev/null +++ b/tools/bazel/npm_package.bzl @@ -0,0 +1,125 @@ +load("@aspect_rules_js//npm:defs.bzl", _npm_package = "npm_package") +load("@bazel_lib//lib:copy_to_bin.bzl", "copy_to_bin") +load("@bazel_lib//lib:expand_template.bzl", "expand_template") +load("@bazel_lib//lib:utils.bzl", "to_label") +load("@jq.bzl//jq:jq.bzl", "jq") +load("@rules_pkg//:pkg.bzl", "pkg_tar") +load("//tools:link_package_json_to_tarballs.bzl", "link_package_json_to_tarballs") +load("//tools:snapshot_repo_filter.bzl", "SNAPSHOT_REPO_JQ_FILTER") +load("//tools:substitutions.bzl", "substitutions") + +def npm_package( + name, + deps = [], + visibility = None, + pkg_deps = [], + stamp_files = [], + pkg_json = "package.json", + extra_substitutions = {}, + replace_prefixes = {}, + **kwargs): + if name != "pkg": + fail("Expected npm_package to be named `pkg`. " + + "This is needed for pnpm workspace integration.") + + # Merge package.json with root package.json and perform various substitutions to + # prepare it for release. For jq docs, see https://stedolan.github.io/jq/manual/. + jq( + name = "basic_substitutions", + # Note: this jq filter relies on the order of the inputs + # buildifier: do not sort + srcs = ["//:package.json", pkg_json], + filter_file = "//tools:package_json_release_filter.jq", + args = ["--slurp"], + out = "substituted/package.json", + ) + + # Copy package.json files to bazel-out so we can use their bazel-out paths to determine + # the corresponding package npm package tgz path for substitutions. + copy_to_bin( + name = "package_json_copy", + srcs = [pkg_json], + ) + pkg_deps_copies = [] + for pkg_dep in pkg_deps: + pkg_label = to_label(pkg_dep) + if pkg_label.name != "package.json": + fail("ERROR: only package.json files allowed in pkg_deps of pkg_npm macro") + pkg_deps_copies.append("@@%s//%s:package_json_copy" % (pkg_label.repo_name, pkg_label.package)) + + # Substitute dependencies on other packages in this repo with tarballs. + link_package_json_to_tarballs( + name = "tar_substitutions", + src = "substituted/package.json", + pkg_deps = [":package_json_copy"] + pkg_deps_copies, + out = "substituted_with_tars/package.json", + ) + + # Substitute dependencies on other packages in this repo with snapshot repos. + jq( + name = "snapshot_repo_substitutions", + srcs = ["substituted/package.json"], + filter = SNAPSHOT_REPO_JQ_FILTER, + out = "substituted_with_snapshot_repos/package.json", + ) + + nostamp_subs = dict(substitutions["nostamp"], **extra_substitutions) + stamp_subs = dict(substitutions["stamp"], **extra_substitutions) + + expand_template( + name = "final_package_json", + template = select({ + # Do local tar substitution if config_setting is true. + "//:package_json_use_tar_deps": "substituted_with_tars/package.json", + # Do snapshot repo substitution if config_setting is true. + "//:package_json_use_snapshot_repo_deps": "substituted_with_snapshot_repos/package.json", + "//conditions:default": "substituted/package.json", + }), + out = "substituted_final/package.json", + substitutions = nostamp_subs, + stamp_substitutions = stamp_subs, + ) + + stamp_targets = [] + for f in stamp_files: + expand_template( + name = "stamp_file_%s" % f, + template = f, + out = "substituted/%s" % f, + substitutions = nostamp_subs, + stamp_substitutions = stamp_subs, + ) + + stamp_targets.append("stamp_file_%s" % f) + + _npm_package( + name = "npm_package", + visibility = visibility, + # Note: Order matters here! Last file takes precedence after replaced prefixes. + srcs = deps + stamp_targets + [":final_package_json"], + replace_prefixes = dict({ + "substituted_final/": "", + "substituted_with_tars/": "", + "substituted_with_snapshot_repos/": "", + "substituted/": "", + }, **replace_prefixes), + allow_overwrites = True, + **kwargs + ) + + # Note: For now, in hybrid mode with RNJS and RJS, we ensure + # both `:pkg` and `:npm_package` work. + native.alias( + name = "pkg", + actual = ":npm_package", + ) + + if pkg_json: + pkg_tar( + name = "npm_package_archive", + srcs = [":pkg"], + extension = "tgz", + # should not be built unless it is a dependency of another rule + tags = ["manual"], + visibility = visibility, + ) diff --git a/tools/build_event_protocol/BUILD b/tools/build_event_protocol/BUILD deleted file mode 100644 index da9d5b617413..000000000000 --- a/tools/build_event_protocol/BUILD +++ /dev/null @@ -1,21 +0,0 @@ -load("@build_bazel_rules_typescript//:defs.bzl", "ts_library", "ts_proto_library") -load("@build_bazel_rules_nodejs//:defs.bzl", "nodejs_binary") - -ts_proto_library( - name = "build_event_stream", - deps = ["//third_party/github.com/bazelbuild/bazel/src/main/java/com/google/devtools/build/lib/buildeventstream/proto"], -) - -ts_library( - name = "parse_bep", - srcs = ["parse_bep.ts"], - deps = [ - ":build_event_stream", - ], -) - -nodejs_binary( - name = "parse", - entry_point = "angular_devkit/tools/build_event_protocol/parse_bep", - data = [":parse_bep"], -) diff --git a/tools/build_event_protocol/README.md b/tools/build_event_protocol/README.md deleted file mode 100644 index 3eaf87961b67..000000000000 --- a/tools/build_event_protocol/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# build_event_protocol - -This protocol is used to make streaming build results available for machines to -read and process. - -See https://docs.bazel.build/versions/master/build-event-protocol.html - -This directory contains an example program that parses a streaming JSON file -containing build events. It's useful for manually testing a program that emits -the build event protocol. - -## Try it - -First build the tool: - -```sh -$ bazel build //tools/build_event_protocol:parse -``` - -Then produce a build event json file: - -$ bazel test //... --build_event_json_file=bep.json - -as soon as that process has started, you should be able to run in another window - -$ bazel-bin/tools/build_event_protocol/parse bep.json diff --git a/tools/build_event_protocol/parse_bep.ts b/tools/build_event_protocol/parse_bep.ts deleted file mode 100644 index 4884318406ca..000000000000 --- a/tools/build_event_protocol/parse_bep.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * @license - * 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 - */ - -/** - * @fileoverview simple program to read the build event protocol and convert to - * UI quasi-events. - * - * See the README.md in this directory for usage - */ - -/// -import * as fs from 'fs'; -import { Observable, Subject } from 'rxjs'; -import { filter } from 'rxjs/operators'; -import { build_event_stream as bes } from './build_event_stream'; - -function updateUi(s: string, ...more: Array<{}>) { - console.error('-->', s, ...more); -} - -// typeof bes.BuildEventId.id -type payloadType = 'unknown'|'progress'|'started'|'unstructuredCommandLine'| - 'structuredCommandLine'|'workspaceStatus'|'optionsParsed'|'fetch'| - 'configuration'|'targetConfigured'|'pattern'|'patternSkipped'|'namedSet'| - 'targetCompleted'|'actionCompleted'|'unconfiguredLabel'|'configuredLabel'| - 'testResult'|'testSummary'|'buildFinished'|'buildToolLogs'|'buildMetrics'; - -function filterType(s: payloadType) { - return filter((evt: bes.BuildEvent) => { - if (!evt.id) { - throw new Error(`expected BuildEvent to contain id - ${JSON.stringify(evt)}`); - } - return Object.keys(evt.id)[0] === s; - }); -} - -async function main(argv: string[]): Promise<0|1> { - const s = new Subject(); - const o: Observable = s.asObservable(); - o.pipe(filterType('testSummary')).subscribe({ - next: (evt: bes.BuildEvent) => { - if (!evt.id || !evt.id.testSummary || !evt.testSummary) { - throw new Error( - `expected BuildEvent to contain id, testSummary, id.testSummary - ${JSON.stringify(evt)}`); - } - updateUi('Test result', { - label: evt.id.testSummary.label, - overallStatus: evt.testSummary.overallStatus, - }); - } - }); - - o.pipe(filterType('buildFinished')).subscribe({ - next: (evt: bes.BuildEvent) => { - if (!evt.finished || !evt.finished.exitCode) { - throw new Error( - `expected BuildEvent to contain finished, finished.exitCode - ${JSON.stringify(evt)}`); - } - const exitName = evt.finished.exitCode.name; - switch (exitName) { - case 'SUCCESS': - updateUi('Thumbs up'); - break; - case 'TESTS_FAILED': - updateUi('Some tests failed'); - break; - case 'INTERRUPTED': - updateUi('Cancelled'); - break; - case 'PARSING_FAILURE': - updateUi('Error in build configuration file, report to expert', { - someDebugThatsUsefulToTheExpert: 'a-file-path', - }); - break; - default: - throw new Error(`Unhandled exitName ${exitName}`); - } - }, - error: (e) => console.error('Unrecoverable error ', e) - }); - - // Push values into the subject - // TODO: should be streaming from file - // const stream = fs.createReadStream(argv[0]); - // stream.on('data') - // stream.on('end) - // etc. - const content = fs.readFileSync(argv[0], {'encoding': 'utf-8'}); - content.split(/[\r\n]+/).filter(l => !!l).forEach( - evt => s.next(JSON.parse(evt))); - s.complete(); - - await o.toPromise(); - return 0; -} - - -if (require.main === module) { - // Allow this program to run under bazel run - const wd = process.env['BUILD_WORKING_DIRECTORY']; - if (wd) { - process.chdir(wd); - } - - main(process.argv.slice(2)) - .then(exitCode => process.exitCode = exitCode, e => { - throw new Error(e); - }); -} diff --git a/tools/defaults.bzl b/tools/defaults.bzl new file mode 100644 index 000000000000..20153614ac80 --- /dev/null +++ b/tools/defaults.bzl @@ -0,0 +1,98 @@ +load("@aspect_rules_jasmine//jasmine:defs.bzl", _jasmine_test = "jasmine_test") +load("@aspect_rules_js//js:defs.bzl", _js_binary = "js_binary") +load("@bazel_lib//lib:copy_to_bin.bzl", _copy_to_bin = "copy_to_bin") +load("@devinfra//bazel/ts_project:index.bzl", "strict_deps_test") +load("@rules_angular//src/ng_package:index.bzl", _ng_package = "ng_package") +load("@rules_angular//src/ts_project:index.bzl", _ts_project = "ts_project") +load("//tools:substitutions.bzl", "substitutions") +load("//tools/bazel:npm_package.bzl", _npm_package = "npm_package") + +def ts_project( + name, + deps = [], + tsconfig = None, + testonly = False, + source_map = True, + visibility = None, + **kwargs): + if tsconfig == None: + tsconfig = "//:test-tsconfig" if testonly else "//:build-tsconfig" + + package_json = kwargs.pop("package_json", None) + if not package_json and native.glob(["package.json"], allow_empty = True): + package_json = "package.json" + + _ts_project( + name = name, + testonly = testonly, + declaration = True, + source_map = source_map, + tsconfig = tsconfig, + visibility = visibility, + deps = deps, + **kwargs + ) + + strict_deps_test( + name = "%s_strict_deps_test" % name, + srcs = kwargs.get("srcs", []), + data = kwargs.get("data", []), + package_json = package_json, + tsconfig = tsconfig, + deps = deps, + ) + +def npm_package(**kwargs): + _npm_package(**kwargs) + +def copy_to_bin(**kwargs): + _copy_to_bin(**kwargs) + +def js_binary(**kwargs): + _js_binary(**kwargs) + +def ng_package(deps = [], extra_substitutions = {}, **kwargs): + nostamp_subs = dict(substitutions["nostamp"], **extra_substitutions) + stamp_subs = dict(substitutions["stamp"], **extra_substitutions) + + _ng_package( + deps = deps, + license = "//:LICENSE", + substitutions = select({ + "//:stamp": stamp_subs, + "//conditions:default": nostamp_subs, + }), + **kwargs + ) + +def jasmine_test(data = [], args = [], **kwargs): + # Create relative path to root, from current package dir. Necessary as + # we change the `chdir` below to the package directory. + relative_to_root = "/".join([".."] * len(native.package_name().split("/"))) + + # Chromium browser toolchain + env = kwargs.pop("env", {}) + env.update({ + "CHROME_BIN": "$(CHROME-HEADLESS-SHELL)", + "CHROME_PATH": "$(CHROME-HEADLESS-SHELL)", + "CHROMEDRIVER_BIN": "$(CHROMEDRIVER)", + }) + toolchains = kwargs.pop("toolchains", []) + toolchains = toolchains + ["@rules_browsers//browsers/chromium:toolchain_alias"] + data = data + ["@rules_browsers//browsers/chromium"] + + _jasmine_test( + node_modules = "//:node_modules", + chdir = native.package_name(), + env = env, + toolchains = toolchains, + args = [ + "--require=%s/node_modules/source-map-support/register.js" % relative_to_root, + # Escape so that the `js_binary` launcher triggers Bash expansion. + "'**/*+(.|_)spec.js'", + "'**/*+(.|_)spec.mjs'", + "'**/*+(.|_)spec.cjs'", + ] + args, + data = data + ["//:node_modules/source-map-support"], + **kwargs + ) diff --git a/tools/link_package_json_to_tarballs.bzl b/tools/link_package_json_to_tarballs.bzl new file mode 100644 index 000000000000..4a31cd194bf7 --- /dev/null +++ b/tools/link_package_json_to_tarballs.bzl @@ -0,0 +1,87 @@ +# Copyright Google LLC. All Rights Reserved. +# +# Use of this source code is governed by an MIT-style license that can be +# found in the LICENSE file at https://angular.dev/license +load("@bazel_lib//lib:utils.bzl", "to_label") +load("@jq.bzl//jq:jq.bzl", "jq") + +def link_package_json_to_tarballs(name, src, pkg_deps, out): + """Substitute tar paths into a package.json file for the packages it depends on. + + src and pkg_deps must be labels in the bazel-out tree for the derived path to the npm_package_archive.tgz to be correct. + + Args: + name: Name of the rule + src: package.json file to perform substitions on + pkg_deps: package.json files of dependencies to substitute + out: Output package.json file + """ + + src_pkg = to_label(src).package + + # Generate partial jq filters for each dependent package that, when run + # against a package.json file, can replace its dependency with a tar path. + filter_files = [] + for i, pkg_dep in enumerate(pkg_deps): + pkg_dep_name = "%s_%s.name" % (name, i) + pkg_dep_filter = "%s_%s.filter" % (name, i) + jq( + name = "%s_%s_name" % (name, i), + srcs = [pkg_dep], + filter = ".name", + out = pkg_dep_name, + ) + + srcs = [ + pkg_dep_name, + pkg_dep, + ] + + # Add dependent tars as srcs to include them in the dependency graph, except + # for the tar for this package as that would create a circular dependency. + pkg_label = to_label(pkg_dep) + if pkg_label.package != src_pkg: + pkg_tar = "@@%s//%s:npm_package_archive.tgz" % (pkg_label.repo_name, pkg_label.package) + srcs.append(pkg_tar) + + # Deriving the absolute path to the tar in the execroot requries different + # commands depending on whether or not the action is sandboxed. + abs_path_sandbox = "readlink $(execpath {pkg_dep})".format(pkg_dep = pkg_dep) + abs_path_nosandbox = "(cd $$(dirname $(execpath {pkg_dep})) && pwd)".format(pkg_dep = pkg_dep) + + native.genrule( + name = "%s_%s_filter" % (name, i), + srcs = srcs, + cmd = """ + TAR=$$(dirname $$({abs_path_sandbox} || {abs_path_nosandbox}))/npm_package_archive.tgz + PKGNAME=$$(cat $(execpath {pkg_name})) + if [[ "$$TAR" != *bazel-out* ]]; then + echo "ERROR: package.json passed to substitute_tar_deps must be in the output tree. You can use copy_to_bin to copy a source file to the output tree." + exit 1 + fi + echo "(..|objects|select(has($${{PKGNAME}})))[$${{PKGNAME}}] |= \\"file:$${{TAR}}\\"" > $@ + """.format( + pkg_name = pkg_dep_name, + abs_path_sandbox = abs_path_sandbox, + abs_path_nosandbox = abs_path_nosandbox, + ), + outs = [pkg_dep_filter], + ) + filter_files.append(pkg_dep_filter) + + # Combine all of the filter files into a single filter by joining with | + filter = "%s.filter" % name + native.genrule( + name = "%s_filter" % name, + srcs = filter_files, + cmd = "cat $(SRCS) | sed '$$!s#$$# |#' > $@", + outs = [filter], + ) + + # Generate final package.json with tar substitutions using the above filter + jq( + name = name, + srcs = [src], + filter_file = filter, + out = out, + ) diff --git a/tools/ng_cli_schema_generator.bzl b/tools/ng_cli_schema_generator.bzl new file mode 100644 index 000000000000..86d9552dd70c --- /dev/null +++ b/tools/ng_cli_schema_generator.bzl @@ -0,0 +1,13 @@ +load("@aspect_rules_js//js:defs.bzl", "js_run_binary") + +def cli_json_schema(name, src, out, data = []): + js_run_binary( + name = name, + outs = [out], + tags = ["schema"], + srcs = [src] + data, + tool = "//tools:ng_cli_schema", + progress_message = "Generating CLI interface from %s" % src, + mnemonic = "NgCliJsonSchema", + args = ["$(rootpath %s)" % src, "$(rootpath %s)" % out], + ) diff --git a/tools/ng_cli_schema_generator.js b/tools/ng_cli_schema_generator.js new file mode 100644 index 000000000000..88ea5531c41c --- /dev/null +++ b/tools/ng_cli_schema_generator.js @@ -0,0 +1,109 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +const { readFileSync, writeFileSync, mkdirSync } = require('fs'); +const { resolve, dirname } = require('path'); + +/** + * Generator the Angular CLI workspace schema file. + */ +function generate(inPath, outPath) { + // While on paper we could use quicktype for this. + // Quicktype doesn't handle `patternProperties` and `oneOf` that well. + + const jsonSchema = readFileSync(inPath, 'utf8'); + const nestedDefinitions = {}; + const schemaParsed = JSON.parse(jsonSchema, (key, value) => { + if (key === '$ref' && typeof value === 'string' && !value.startsWith('#')) { + // Resolve $ref and camelize key + const definitionKey = value + .replace(/(\.json|src)/g, '') + .split(/\\|\/|_|-|\./) + .filter((p) => !!p) + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(''); + + const nestedSchemaPath = resolve(dirname(inPath), value); + const nestedSchema = readFileSync(nestedSchemaPath, 'utf8'); + const nestedSchemaJson = JSON.parse(nestedSchema, (key, value) => { + switch (key) { + case '$ref': + if (value.startsWith('#/definitions/')) { + return value.replace('#/definitions/', `#/definitions/${definitionKey}/definitions/`); + } else { + throw new Error(`Error while resolving $ref ${value} in ${nestedSchemaPath}.`); + } + case '$id': + case 'id': + case '$schema': + case 'required': + case 'x-prompt': + case 'x-user-analytics': + return undefined; + default: + return value; + } + }); + + nestedDefinitions[definitionKey] = nestedSchemaJson; + + return `#/definitions/${definitionKey}`; + } + + return key === '' + ? { + ...value, + definitions: { + ...value.definitions, + ...nestedDefinitions, + }, + } + : value; + }); + + const buildWorkspaceDirectory = process.env['BUILD_WORKSPACE_DIRECTORY'] || '.'; + outPath = resolve(buildWorkspaceDirectory, outPath); + + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync( + outPath, + JSON.stringify( + schemaParsed, + (key, value) => { + if (key === 'x-deprecated') { + // Needed for IDEs, and will be replaced to 'deprecated' later on. This must be a boolean. + // https://json-schema.org/draft/2020-12/json-schema-validation.html#name-deprecated + return !!value; + } + + return value; + }, + 2, + ).replace(/"x-deprecated"/g, '"deprecated"'), + ); +} + +if (require.main === module) { + const argv = process.argv.slice(2); + if (argv.length !== 2) { + console.error('Must include 2 arguments.'); + process.exit(1); + } + + const [inPath, outPath] = argv; + + try { + generate(inPath, outPath); + } catch (error) { + console.error('An error happened:'); + console.error(error); + process.exit(127); + } +} + +exports.generate = generate; diff --git a/tools/package_json_release_filter.jq b/tools/package_json_release_filter.jq new file mode 100644 index 000000000000..4e7c3c639c67 --- /dev/null +++ b/tools/package_json_release_filter.jq @@ -0,0 +1,37 @@ +# Copyright Google LLC. All Rights Reserved. +# +# Use of this source code is governed by an MIT-style license that can be +# found in the LICENSE file at https://angular.dev/license +# +# This filter combines a subproject package.json with the root package.json +# and performs substitutions to prepare it for release. It should be called +# with the --slurp argument and be passed the root pacakge.json followed by +# the subproject package.json. +# +# See jq docs for filter syntax: https://stedolan.github.io/jq/manual/. + +.[0] as $root +| .[1] as $proj + +# Get the fields from root package.json that should override the project +# package.json, i.e., every field except the following +| ($root + | del(.bin, .description, .dependencies, .name, .main, .peerDependencies, .optionalDependencies, .typings, .version, .private, .workspaces, .resolutions, .scripts, .["ng-update"], .pnpm, .dependenciesMeta, .packageManager) +) as $root_overrides + +# Use the project package.json as a base and override other fields from root +| $proj + $root_overrides + +# Combine keywords from both +| .keywords = ($root.keywords + $proj.keywords | unique) + +# Remove devDependencies +| del(.devDependencies) + +# Add engines; versions substituted via pkg_npm ++ {"engines": {"node": "0.0.0-ENGINES-NODE", "npm": "0.0.0-ENGINES-NPM", "yarn": "0.0.0-ENGINES-YARN"}} + +# Remove all `workspace:` pnpm prefixes. Afterwards we can conveniently rely on +# substitutions from the stamp values. Note that we are doing it this way because +# substitutions can apply to multiple files, and `workspace:` can't be reliably replaced. +| walk(if type == "string" and startswith("workspace:") then sub("workspace:"; "") else . end) diff --git a/tools/quicktype_runner.js b/tools/quicktype_runner.js index 65c6501536f2..be4745f62842 100644 --- a/tools/quicktype_runner.js +++ b/tools/quicktype_runner.js @@ -1,15 +1,15 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights 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 */ + const fs = require('fs'); const path = require('path'); const { InputData, - JSONSchema, JSONSchemaInput, JSONSchemaStore, TypeScriptTargetLanguage, @@ -36,9 +36,6 @@ const header = ` // THIS FILE IS AUTOMATICALLY GENERATED. TO UPDATE THIS FILE YOU NEED TO CHANGE THE // CORRESPONDING JSON SCHEMA FILE, THEN RUN devkit-admin build (or bazel build ...). -// tslint:disable:no-global-tslint-disable -// tslint:disable - `; // Footer to add to all files. @@ -55,7 +52,7 @@ class FetchingJSONSchemaStore extends JSONSchemaStore { } async fetch(address) { - const URL = require("url"); + const URL = require('url'); const url = URL.parse(address); let content = null; if (url.protocol === 'ng-cli:') { @@ -63,7 +60,6 @@ class FetchingJSONSchemaStore extends JSONSchemaStore { content = fs.readFileSync(filePath, 'utf-8').trim(); } else if (url.hostname) { try { - const fetch = require("node-fetch"); const response = await fetch(address); content = response.text(); } catch (e) { @@ -88,15 +84,16 @@ class FetchingJSONSchemaStore extends JSONSchemaStore { return undefined; } - return parseJSON(content, "JSON Schema", address); + content = appendDeprecatedDescription(content); + + return parseJSON(content, 'JSON Schema', address); } } - /** * Create the TS file from the schema, and overwrite the outPath (or log). - * @param {string} inPath - * @param {string} outPath + * @param {string} inPath + * @param {string} outPath */ async function main(inPath, outPath) { const content = await generate(inPath); @@ -111,12 +108,12 @@ async function main(inPath, outPath) { fs.writeFileSync(outPath, content, 'utf-8'); } - async function generate(inPath) { // Best description of how to use the API was found at // https://blog.quicktype.io/customizing-quicktype/ const inputData = new InputData(); - const source = { name: 'Schema', schema: fs.readFileSync(inPath, 'utf-8') }; + const content = fs.readFileSync(inPath, 'utf-8'); + const source = { name: 'Schema', schema: appendDeprecatedDescription(content) }; await inputData.addSource('schema', source, () => { return new JSONSchemaInput(new FetchingJSONSchemaStore(inPath)); @@ -129,14 +126,39 @@ async function generate(inPath) { inputData, alphabetizeProperties: true, rendererOptions: { - 'just-types': 'true', - 'explicit-unions': 'true', + 'prefer-types': true, + 'just-types': true, + 'prefer-unions': false, + 'explicit-unions': true, + 'acronym-style': 'camel', + 'prefer-unknown': false, }, }); return header + lines.join('\n') + footer; } +/** + * Converts `x-deprecated` to `@deprecated` comments. + * @param {string} schema + */ +function appendDeprecatedDescription(schema) { + const content = JSON.parse(schema); + const props = content.properties; + + for (const key in props) { + let { description = '', 'x-deprecated': deprecated } = props[key]; + if (!deprecated) { + continue; + } + + description += '\n@deprecated' + (typeof deprecated === 'string' ? ` ${deprecated}` : ''); + props[key].description = description; + } + + return JSON.stringify(content); +} + if (require.main === module) { // Parse arguments and run main(). const argv = process.argv.slice(2); @@ -147,7 +169,7 @@ if (require.main === module) { main(argv[0], argv[1]) .then(() => process.exit(0)) - .catch(err => { + .catch((err) => { console.error('An error happened:'); console.error(err); process.exit(127); diff --git a/tools/snapshot_repo_filter.bzl b/tools/snapshot_repo_filter.bzl new file mode 100644 index 000000000000..fbd9672c07a9 --- /dev/null +++ b/tools/snapshot_repo_filter.bzl @@ -0,0 +1,28 @@ +# Copyright Google LLC. All Rights Reserved. +# +# Use of this source code is governed by an MIT-style license that can be +# found in the LICENSE file at https://angular.dev/license + +load("//:constants.bzl", "SNAPSHOT_REPOS") + +def _generate_snapshot_repo_filter(): + individual_pkg_filters = [] + for pkg_name, snapshot_repo in SNAPSHOT_REPOS.items(): + individual_pkg_filters.append( + """ + . as $root + | [paths(..)] + | [(.[] | select( + contains(["{pkg_name}"]) and + contains(["peerDependenciesMeta"]) != true))] as $paths + | $paths | reduce $paths[] as $path ($root; setpath($path; "github:{snapshot_repo}#BUILD_SCM_HASH-PLACEHOLDER")) | . + """.format( + pkg_name = pkg_name, + snapshot_repo = snapshot_repo, + ), + ) + + return " | ".join(individual_pkg_filters) + +# jq filter that replaces package.json dependencies with snapshot repos +SNAPSHOT_REPO_JQ_FILTER = _generate_snapshot_repo_filter() diff --git a/tools/substitutions.bzl b/tools/substitutions.bzl new file mode 100644 index 000000000000..dc40701971ff --- /dev/null +++ b/tools/substitutions.bzl @@ -0,0 +1,37 @@ +load( + "//:constants.bzl", + "ANGULAR_FW_PEER_DEP", + "ANGULAR_FW_VERSION", + "BASELINE_DATE", + "NG_PACKAGR_PEER_DEP", + "NG_PACKAGR_VERSION", + "RELEASE_ENGINES_NODE", + "RELEASE_ENGINES_NPM", + "RELEASE_ENGINES_YARN", +) + +_stamp_substitutions = { + # Version of the local package being built, generated via the `--workspace_status_command` flag. + "0.0.0-PLACEHOLDER": "{{STABLE_PROJECT_VERSION}}", + "0.0.0-EXPERIMENTAL-PLACEHOLDER": "{{STABLE_PROJECT_EXPERIMENTAL_VERSION}}", + # --- + "BUILD_SCM_HASH-PLACEHOLDER": "{{BUILD_SCM_ABBREV_HASH}}", + "BASELINE-DATE-PLACEHOLDER": BASELINE_DATE, + "0.0.0-ENGINES-NODE": RELEASE_ENGINES_NODE, + "0.0.0-ENGINES-NPM": RELEASE_ENGINES_NPM, + "0.0.0-ENGINES-YARN": RELEASE_ENGINES_YARN, + "0.0.0-NG-PACKAGR-VERSION": NG_PACKAGR_VERSION, + "0.0.0-NG-PACKAGR-PEER-DEP": NG_PACKAGR_PEER_DEP, + "0.0.0-ANGULAR-FW-VERSION": ANGULAR_FW_VERSION, + "0.0.0-ANGULAR-FW-PEER-DEP": ANGULAR_FW_PEER_DEP, +} + +_no_stamp_substitutions = dict(_stamp_substitutions, **{ + "0.0.0-PLACEHOLDER": "0.0.0", + "0.0.0-EXPERIMENTAL-PLACEHOLDER": "0.0.0", +}) + +substitutions = { + "stamp": _stamp_substitutions, + "nostamp": _no_stamp_substitutions, +} diff --git a/tools/test/BUILD.bazel b/tools/test/BUILD.bazel new file mode 100644 index 000000000000..71ef3e3c2794 --- /dev/null +++ b/tools/test/BUILD.bazel @@ -0,0 +1,32 @@ +load("@bazel_skylib//rules:diff_test.bzl", "diff_test") +load("@jq.bzl//jq:jq.bzl", "jq") + +jq( + name = "final_package_json", + # This jq filter relies on the order of the inputs + # buildifier: do not sort + srcs = [ + "root_package.json", + "project_package.json", + ], + args = [ + "--slurp", + ], + filter_file = "//tools:package_json_release_filter.jq", +) + +# jq outputs CR on windows https://github.com/stedolan/jq/issues/92 +# strip the CRs to do a correct comparison on all platforms +genrule( + name = "final_package_json_cr_stripped", + srcs = [":final_package_json"], + outs = ["final_package_json_cr_stripped.json"], + cmd = "cat $(execpath :final_package_json) | sed \"s#\\r##\" > $@", +) + +# Test correctness of the filter that prepares each project's package.json file for release +diff_test( + name = "package_json_filter_test", + file1 = "expected_package.json", + file2 = ":final_package_json_cr_stripped", +) diff --git a/tools/test/expected_package.json b/tools/test/expected_package.json new file mode 100644 index 000000000000..6630c9062f8a --- /dev/null +++ b/tools/test/expected_package.json @@ -0,0 +1,42 @@ +{ + "name": "project", + "version": "0.0.0-SNAPSHOT", + "description": "Project package.json", + "main": "project/index.js", + "bin": { + "projectfoo": "./bin/project-foo.js" + }, + "keywords": [ + "a", + "b", + "c" + ], + "scripts": { + "build": "node project-build-script" + }, + "repository": { + "type": "git", + "url": "https://github.com/angular/angular-cli.git" + }, + "author": "Angular Authors", + "license": "MIT", + "bugs": { + "url": "https://github.com/angular/angular-cli/issues" + }, + "homepage": "https://github.com/angular/angular-cli", + "dependencies": { + "@project/foo": "1.0.0", + "@project/bar": "2.0.0" + }, + "ng-update": { + "migrations": "@project/migration-collection.json", + "packageGroup": { + "@project/abc": "0.0.0" + } + }, + "engines": { + "node": "0.0.0-ENGINES-NODE", + "npm": "0.0.0-ENGINES-NPM", + "yarn": "0.0.0-ENGINES-YARN" + } +} diff --git a/tools/test/project_package.json b/tools/test/project_package.json new file mode 100644 index 000000000000..894c9f4cf3f2 --- /dev/null +++ b/tools/test/project_package.json @@ -0,0 +1,39 @@ +{ + "name": "project", + "version": "0.0.0-SNAPSHOT", + "description": "Project package.json", + "main": "project/index.js", + "bin": { + "projectfoo": "./bin/project-foo.js" + }, + "keywords": [ + "b", + "c" + ], + "scripts": { + "build": "node project-build-script" + }, + "repository": { + "type": "git", + "url": "https://github.com/angular/angular-cli.git" + }, + "author": "Angular Authors", + "license": "MIT", + "bugs": { + "url": "https://github.com/angular/angular-cli/issues" + }, + "homepage": "https://github.com/angular/angular-cli", + "dependencies": { + "@project/foo": "1.0.0", + "@project/bar": "2.0.0" + }, + "devDependencies": { + "@project/devdep": "1.2.3" + }, + "ng-update": { + "migrations": "@project/migration-collection.json", + "packageGroup": { + "@project/abc": "0.0.0" + } + } +} diff --git a/tools/test/root_package.json b/tools/test/root_package.json new file mode 100644 index 000000000000..e0b263aef042 --- /dev/null +++ b/tools/test/root_package.json @@ -0,0 +1,37 @@ +{ + "name": "root", + "version": "1.0.0-next.1", + "private": true, + "description": "Root package.json", + "bin": { + "root-foo": "./bin/root-foo.js", + "root-bar": "./bin/root-bar.js" + }, + "keywords": [ + "a", + "b" + ], + "scripts": { + "build": "node root-build-script" + }, + "repository": { + "type": "git", + "url": "https://github.com/angular/angular-cli.git" + }, + "author": "Angular Authors", + "license": "MIT", + "bugs": { + "url": "https://github.com/angular/angular-cli/issues" + }, + "homepage": "https://github.com/angular/angular-cli", + "workspaces": { + "packages": ["packages/root/foo/*", "packages/root/bar/*"] + }, + "resolutions": { + "root/foo/bar": "1.0.0" + }, + "devDependencies": { + "@root/foo": "1.0.0", + "@root/bar": "2.0.0" + } +} diff --git a/tools/toolchain_info.bzl b/tools/toolchain_info.bzl new file mode 100644 index 000000000000..b920c8a8f88a --- /dev/null +++ b/tools/toolchain_info.bzl @@ -0,0 +1,31 @@ +# look at the toolchains registered in the workspace file with nodejs_register_toolchains + +# the name can be anything the user wants this is just added to the target to create unique names +# the order will match against the order in the TOOLCHAIN_VERSION list. +TOOLCHAINS_NAMES = [ + "node22", + "node24", + "node26", +] + +# this is the list of toolchains that should be used and are registered with nodejs_register_toolchains in the WORKSPACE file +TOOLCHAINS_VERSIONS = [ + select({ + "@bazel_tools//src/conditions:linux_x86_64": "@node22_linux_amd64//:node_toolchain", + "@bazel_tools//src/conditions:darwin": "@node22_darwin_amd64//:node_toolchain", + "@bazel_tools//src/conditions:windows": "@node22_windows_amd64//:node_toolchain", + }), + select({ + "@bazel_tools//src/conditions:linux_x86_64": "@node24_linux_amd64//:node_toolchain", + "@bazel_tools//src/conditions:darwin": "@node24_darwin_amd64//:node_toolchain", + "@bazel_tools//src/conditions:windows": "@node24_windows_amd64//:node_toolchain", + }), + select({ + "@bazel_tools//src/conditions:linux_x86_64": "@node26_linux_amd64//:node_toolchain", + "@bazel_tools//src/conditions:darwin": "@node26_darwin_amd64//:node_toolchain", + "@bazel_tools//src/conditions:windows": "@node26_windows_amd64//:node_toolchain", + }), +] + +# A default toolchain for use when only one is necessary +DEFAULT_TOOLCHAIN_VERSION = TOOLCHAINS_VERSIONS[len(TOOLCHAINS_VERSIONS) - 1] diff --git a/tools/toolchains/BUILD.bazel b/tools/toolchains/BUILD.bazel new file mode 100644 index 000000000000..09bb94865046 --- /dev/null +++ b/tools/toolchains/BUILD.bazel @@ -0,0 +1,32 @@ +load("@rules_cc//cc:defs.bzl", "cc_toolchain") +load(":dummy_cc_toolchain.bzl", "dummy_cc_toolchain_config") + +# This defines a dummy C++ toolchain for Windows. +# Without this, the build fails with "Unable to find a CC toolchain using toolchain resolution". +dummy_cc_toolchain_config(name = "dummy_cc_toolchain_config") + +filegroup(name = "empty") + +cc_toolchain( + name = "dummy_cc_toolchain", + all_files = ":empty", + compiler_files = ":empty", + dwp_files = ":empty", + linker_files = ":empty", + objcopy_files = ":empty", + strip_files = ":empty", + supports_param_files = 0, + toolchain_config = ":dummy_cc_toolchain_config", + toolchain_identifier = "dummy_cc_toolchain", +) + +toolchain( + name = "dummy_cc_windows_no_exec_toolchain", + exec_compatible_with = [], + target_compatible_with = [ + "@platforms//os:windows", + "@platforms//cpu:x86_64", + ], + toolchain = ":dummy_cc_toolchain", + toolchain_type = "@rules_cc//cc:toolchain_type", +) diff --git a/tools/toolchains/dummy_cc_toolchain.bzl b/tools/toolchains/dummy_cc_toolchain.bzl new file mode 100644 index 000000000000..a18a9c780915 --- /dev/null +++ b/tools/toolchains/dummy_cc_toolchain.bzl @@ -0,0 +1,28 @@ +""" +This file defines a dummy C++ toolchain for Windows. +It is needed to satisfy Bazel's toolchain resolution when cross-compiling for Windows on Linux. +Some rules (e.g. rules_nodejs, js_test) or their dependencies may trigger C++ toolchain resolution +even if no actual C++ compilation is performed for the target platform. +Without this, the build fails with "Unable to find a CC toolchain using toolchain resolution". +""" + +load("@rules_cc//cc:defs.bzl", "cc_common") + +def _impl(ctx): + return cc_common.create_cc_toolchain_config_info( + ctx = ctx, + toolchain_identifier = "dummy-toolchain", + host_system_name = "local", + target_system_name = "local", + target_cpu = "x64_windows", + target_libc = "unknown", + compiler = "dummy", + abi_version = "unknown", + abi_libc_version = "unknown", + ) + +dummy_cc_toolchain_config = rule( + implementation = _impl, + attrs = {}, + provides = [CcToolchainConfigInfo], +) diff --git a/tools/ts_json_schema.bzl b/tools/ts_json_schema.bzl index 720117f9e2ec..dab651d0d7e0 100644 --- a/tools/ts_json_schema.bzl +++ b/tools/ts_json_schema.bzl @@ -1,80 +1,20 @@ -# 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 +load("@aspect_rules_js//js:defs.bzl", "js_run_binary") -load("@build_bazel_rules_typescript//:defs.bzl", "ts_library") - -# @external_begin -def _ts_json_schema_interface_impl(ctx): - args = [ - ctx.files.src[0].path, - ctx.outputs.ts.path, - ] - - ctx.actions.run( - inputs = ctx.files.src + ctx.files.data, - executable = ctx.executable._binary, - outputs = [ctx.outputs.ts], - arguments = args, - ) - - return [DefaultInfo()] - - -_ts_json_schema_interface = rule( - _ts_json_schema_interface_impl, - attrs = { - "src": attr.label( - allow_files = FileType([ - ".json", - ]), - mandatory = True, - ), - "out": attr.string( - mandatory = True, - ), - "data": attr.label_list( - allow_files = FileType([ - ".json", - ]), - ), - "_binary": attr.label( - default = Label("//tools:quicktype_runner"), - executable = True, - cfg = "host", - ), - }, - outputs = { - "ts": "%{out}" - }, -) -# @external_end - - -# Generates a library that contains the interface for a JSON Schema file. Takes a single `src` -# argument as input, an optional data field for reference files, and produces a ts_library() -# rule containing the typescript interface. +# Generates a TS file that contains the interface for a JSON Schema file. Takes a single `src` +# argument as input, an optional data field for reference files, and produces a +# _ts_json_schema_interface() rule containing the typescript interface. # The file produced will have the same name, with the extension replaced from `.json` to `.ts`. # Any filename collision will be an error thrown by Bazel. def ts_json_schema(name, src, data = []): out = src.replace(".json", ".ts") - # @external_begin - _ts_json_schema_interface( + js_run_binary( name = name + ".interface", - src = src, - out = out, - data = data, - ) - # @external_end - - ts_library( - name = name, - # Remove these to empty the rule, since those files are also compiled elsewhere. - # @external_begin - srcs = [ - out, - ] - # @external_end + outs = [out], + srcs = [src] + data, + tags = ["schema"], + tool = "//tools:quicktype_runner", + progress_message = "Generating TS interface for %s" % src, + mnemonic = "TsJsonSchema", + args = ["$(rootpath %s)" % src, "$(rootpath %s)" % out], ) diff --git a/tools/yarn/check-yarn.js b/tools/yarn/check-yarn.js deleted file mode 100644 index 2e7d66ef63c4..000000000000 --- a/tools/yarn/check-yarn.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @license - * 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 - */ - -'use strict'; - -if (process.env.npm_execpath.indexOf('yarn') === -1) { - throw new Error( - 'Please use Yarn instead of NPM to install dependencies. See: https://yarnpkg.com/lang/en/docs/install/'); -} diff --git a/tsconfig-build-esm.json b/tsconfig-build-esm.json new file mode 100644 index 000000000000..5d44015a9631 --- /dev/null +++ b/tsconfig-build-esm.json @@ -0,0 +1,18 @@ +/** + * Root tsconfig file for use building all Angular packages. Note there is no rootDir + * and therefore any tsconfig in the package directory will need to define its own + * rootDir. + */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "esnext", + "target": "es2022", + "moduleResolution": "bundler", + // don't auto-discover @types/node, it results in a ///=2.2.7 <3" - -abbrev@1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" - integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== - -abbrev@1.0.x: - version "1.0.9" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.0.9.tgz#91b4792588a7738c25f35dd6f63752a2f8776135" - integrity sha1-kbR5JYinc4wl813W9jdSovh3YTU= - -accepts@~1.3.4, accepts@~1.3.5: - version "1.3.5" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.5.tgz#eb777df6011723a3b14e8a72c0805c8e86746bd2" - integrity sha1-63d99gEXI6OxTopywIBcjoZ0a9I= - dependencies: - mime-types "~2.1.18" - negotiator "0.6.1" - -acorn-dynamic-import@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-3.0.0.tgz#901ceee4c7faaef7e07ad2a47e890675da50a278" - integrity sha512-zVWV8Z8lislJoOKKqdNMOB+s6+XV5WERty8MnKBeFgwA+19XJjJHs2RP5dzM57FftIs+jQnRToLiWazKr6sSWg== - dependencies: - acorn "^5.0.0" - -acorn@^5.0.0, acorn@^5.6.2: - version "5.7.3" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279" - integrity sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw== - -adm-zip@^0.4.9: - version "0.4.11" - resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.11.tgz#2aa54c84c4b01a9d0fb89bb11982a51f13e3d62a" - integrity sha512-L8vcjDTCOIJk7wFvmlEUN7AsSb8T+2JrdP7KINBjzr24TJ5Mwj590sLu3BC7zNZowvJWa/JtPmD8eJCzdtDWjA== - -after@0.8.2: - version "0.8.2" - resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f" - integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8= - -agent-base@^4.1.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9" - integrity sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg== - dependencies: - es6-promisify "^5.0.0" - -ajv-errors@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.0.tgz#ecf021fa108fd17dfb5e6b383f2dd233e31ffc59" - integrity sha1-7PAh+hCP0X37Xms4Py3SM+Mf/Fk= - -ajv-keywords@^3.1.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.2.0.tgz#e86b819c602cf8821ad637413698f1dec021847a" - integrity sha1-6GuBnGAs+IIa1jdBNpjx3sAhhHo= - -ajv@6.5.3, ajv@^6.1.0: - version "6.5.3" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.5.3.tgz#71a569d189ecf4f4f321224fecb166f071dd90f9" - integrity sha512-LqZ9wY+fx3UMiiPd741yB2pj3hhil+hQc8taf4o2QGRFpWgZ2V5C8HA165DY9sS3fJwsk7uT7ZlFEyC3Ig3lLg== - dependencies: - fast-deep-equal "^2.0.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - -ajv@^5.0.0, ajv@^5.1.0, ajv@^5.3.0: - version "5.5.2" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965" - integrity sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU= - dependencies: - co "^4.6.0" - fast-deep-equal "^1.0.0" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.3.0" - -amdefine@>=0.0.4: - version "1.0.1" - resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" - integrity sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU= - -ansi-align@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f" - integrity sha1-w2rsy6VjuJzrVW82kPCx2eNUf38= - dependencies: - string-width "^2.0.0" - -ansi-colors@^3.0.0: - version "3.0.5" - resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.0.5.tgz#cb9dc64993b64fd6945485f797fc3853137d9a7b" - integrity sha512-VVjWpkfaphxUBFarydrQ3n26zX5nIK7hcbT3/ielrvwDDyBBjuh2vuSw1P9zkPq0cfqvdw7lkYHnu+OLSfIBsg== - -ansi-escapes@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.1.0.tgz#f73207bb81207d75fd6c83f125af26eea378ca30" - integrity sha512-UgAb8H9D41AQnu/PbWlCofQVcnV4Gs2bBJi9eZPxfU/hgglFh3SMDMENRIqdr7H6XFnXdoknctFByVsCOotTVw== - -ansi-html@0.0.7: - version "0.0.7" - resolved "https://registry.yarnpkg.com/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e" - integrity sha1-gTWEAhliqenm/QOflA0S9WynhZ4= - -ansi-regex@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" - integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= - -ansi-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" - integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= - -ansi-styles@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" - integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4= - -ansi-styles@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" - integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== - dependencies: - color-convert "^1.9.0" - -anymatch@^1.3.0: - version "1.3.2" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a" - integrity sha512-0XNayC8lTHQ2OI8aljNCN3sSx6hsr/1+rlcDAotXJR7C1oZZHCNsfpbKwMjRA3Uqb5tF1Rae2oloTr4xpq+WjA== - dependencies: - micromatch "^2.1.5" - normalize-path "^2.0.0" - -anymatch@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" - integrity sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw== - dependencies: - micromatch "^3.1.4" - normalize-path "^2.1.1" - -app-root-path@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/app-root-path/-/app-root-path-2.1.0.tgz#98bf6599327ecea199309866e8140368fd2e646a" - integrity sha1-mL9lmTJ+zqGZMJhm6BQDaP0uZGo= - -append-transform@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-1.0.0.tgz#046a52ae582a228bd72f58acfbe2967c678759ab" - integrity sha512-P009oYkeHyU742iSZJzZZywj4QRJdnTWffaKuJQLablCZ1uz6/cW4yaRgcDaoQ+uwOxxnt0gRUcwfsNP2ri0gw== - dependencies: - default-require-extensions "^2.0.0" - -aproba@^1.0.3, aproba@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" - integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== - -are-we-there-yet@~1.1.2: - version "1.1.5" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" - integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== - dependencies: - delegates "^1.0.0" - readable-stream "^2.0.6" - -argparse@^1.0.7: - version "1.0.10" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" - integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== - dependencies: - sprintf-js "~1.0.2" - -arr-diff@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf" - integrity sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8= - dependencies: - arr-flatten "^1.0.1" - -arr-diff@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" - integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA= - -arr-flatten@^1.0.1, arr-flatten@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" - integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== - -arr-union@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" - integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= - -array-find-index@^1.0.1, array-find-index@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1" - integrity sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E= - -array-flatten@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" - integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= - -array-flatten@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-2.1.1.tgz#426bb9da84090c1838d812c8150af20a8331e296" - integrity sha1-Qmu52oQJDBg42BLIFQryCoMx4pY= - -array-ify@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/array-ify/-/array-ify-1.0.0.tgz#9e528762b4a9066ad163a6962a364418e9626ece" - integrity sha1-nlKHYrSpBmrRY6aWKjZEGOlibs4= - -array-slice@^0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/array-slice/-/array-slice-0.2.3.tgz#dd3cfb80ed7973a75117cdac69b0b99ec86186f5" - integrity sha1-3Tz7gO15c6dRF82sabC5nshhhvU= - -array-union@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39" - integrity sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk= - dependencies: - array-uniq "^1.0.1" - -array-uniq@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" - integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY= - -array-unique@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53" - integrity sha1-odl8yvy8JiXMcPrc6zalDFiwGlM= - -array-unique@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" - integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= - -arraybuffer.slice@~0.0.7: - version "0.0.7" - resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675" - integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog== - -arrify@^1.0.0, arrify@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" - integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= - -asap@^2.0.0, asap@~2.0.3: - version "2.0.6" - resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" - integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= - -ascli@~1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/ascli/-/ascli-1.0.1.tgz#bcfa5974a62f18e81cabaeb49732ab4a88f906bc" - integrity sha1-vPpZdKYvGOgcq660lzKrSoj5Brw= - dependencies: - colour "~0.7.1" - optjs "~3.2.2" - -asn1.js@^4.0.0: - version "4.10.1" - resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.10.1.tgz#b9c2bf5805f1e64aadeed6df3a2bfafb5a73f5a0" - integrity sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw== - dependencies: - bn.js "^4.0.0" - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - -asn1@~0.2.3: - version "0.2.4" - resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" - integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== - dependencies: - safer-buffer "~2.1.0" - -assert-plus@1.0.0, assert-plus@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" - integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= - -assert@^1.1.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/assert/-/assert-1.4.1.tgz#99912d591836b5a6f5b345c0f07eefc08fc65d91" - integrity sha1-mZEtWRg2tab1s0XA8H7vwI/GXZE= - dependencies: - util "0.10.3" - -assign-symbols@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" - integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= - -async-each@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" - integrity sha1-GdOGodntxufByF04iu28xW0zYC0= - -async-foreach@^0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/async-foreach/-/async-foreach-0.1.3.tgz#36121f845c0578172de419a97dbeb1d16ec34542" - integrity sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI= - -async-limiter@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8" - integrity sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg== - -async@1.x, async@^1.5.2: - version "1.5.2" - resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" - integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo= - -async@^2.5.0, async@^2.6.0, async@^2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610" - integrity sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ== - dependencies: - lodash "^4.17.10" - -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= - -atob@^2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" - integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== - -autoprefixer@9.1.5, autoprefixer@^9.0.0: - version "9.1.5" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.1.5.tgz#8675fd8d1c0d43069f3b19a2c316f3524e4f6671" - integrity sha512-kk4Zb6RUc58ld7gdosERHMF3DzIYJc2fp5sX46qEsGXQQy5bXsu8qyLjoxuY1NuQ/cJuCYnx99BfjwnRggrYIw== - dependencies: - browserslist "^4.1.0" - caniuse-lite "^1.0.30000884" - normalize-range "^0.1.2" - num2fraction "^1.2.2" - postcss "^7.0.2" - postcss-value-parser "^3.2.3" - -aws-sign2@~0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" - integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= - -aws4@^1.6.0, aws4@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f" - integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ== - -babel-code-frame@^6.22.0, babel-code-frame@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" - integrity sha1-Y/1D99weO7fONZR9uP42mj9Yx0s= - dependencies: - chalk "^1.1.3" - esutils "^2.0.2" - js-tokens "^3.0.2" - -babel-generator@^6.18.0: - version "6.26.1" - resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.1.tgz#1844408d3b8f0d35a404ea7ac180f087a601bd90" - integrity sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA== - dependencies: - babel-messages "^6.23.0" - babel-runtime "^6.26.0" - babel-types "^6.26.0" - detect-indent "^4.0.0" - jsesc "^1.3.0" - lodash "^4.17.4" - source-map "^0.5.7" - trim-right "^1.0.1" - -babel-messages@^6.23.0: - version "6.23.0" - resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e" - integrity sha1-8830cDhYA1sqKVHG7F7fbGLyYw4= - dependencies: - babel-runtime "^6.22.0" - -babel-runtime@^6.22.0, babel-runtime@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" - integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4= - dependencies: - core-js "^2.4.0" - regenerator-runtime "^0.11.0" - -babel-template@^6.16.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-template/-/babel-template-6.26.0.tgz#de03e2d16396b069f46dd9fff8521fb1a0e35e02" - integrity sha1-3gPi0WOWsGn0bdn/+FIfsaDjXgI= - dependencies: - babel-runtime "^6.26.0" - babel-traverse "^6.26.0" - babel-types "^6.26.0" - babylon "^6.18.0" - lodash "^4.17.4" - -babel-traverse@^6.18.0, babel-traverse@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee" - integrity sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4= - dependencies: - babel-code-frame "^6.26.0" - babel-messages "^6.23.0" - babel-runtime "^6.26.0" - babel-types "^6.26.0" - babylon "^6.18.0" - debug "^2.6.8" - globals "^9.18.0" - invariant "^2.2.2" - lodash "^4.17.4" - -babel-types@^6.18.0, babel-types@^6.26.0: - version "6.26.0" - resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497" - integrity sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc= - dependencies: - babel-runtime "^6.26.0" - esutils "^2.0.2" - lodash "^4.17.4" - to-fast-properties "^1.0.3" - -babylon@^6.18.0: - version "6.18.0" - resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3" - integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ== - -backo2@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" - integrity sha1-MasayLEpNjRj41s+u2n038+6eUc= - -balanced-match@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" - integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= - -base64-arraybuffer@0.1.5: - version "0.1.5" - resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8" - integrity sha1-c5JncZI7Whl0etZmqlzUv5xunOg= - -base64-js@^1.0.2: - version "1.3.0" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3" - integrity sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw== - -base64id@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/base64id/-/base64id-1.0.0.tgz#47688cb99bb6804f0e06d3e763b1c32e57d8e6b6" - integrity sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY= - -base@^0.11.1: - version "0.11.2" - resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" - integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg== - dependencies: - cache-base "^1.0.1" - class-utils "^0.3.5" - component-emitter "^1.2.1" - define-property "^1.0.0" - isobject "^3.0.1" - mixin-deep "^1.2.0" - pascalcase "^0.1.1" - -batch@0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" - integrity sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY= - -bcrypt-pbkdf@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" - integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= - dependencies: - tweetnacl "^0.14.3" - -better-assert@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522" - integrity sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI= - dependencies: - callsite "1.0.0" - -big.js@^3.1.3: - version "3.2.0" - resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e" - integrity sha512-+hN/Zh2D08Mx65pZ/4g5bsmNiZUuChDiQfTUQ7qJr4/kuopCr88xZsAXv6mBoZEsUI4OuGHlX59qE94K2mMW8Q== - -binary-extensions@^1.0.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205" - integrity sha1-RqoXUftqL5PuXmibsQh9SxTGwgU= - -blob@0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.4.tgz#bcf13052ca54463f30f9fc7e95b9a47630a94921" - integrity sha1-vPEwUspURj8w+fx+lbmkdjCpSSE= - -block-stream@*: - version "0.0.9" - resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" - integrity sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo= - dependencies: - inherits "~2.0.0" - -blocking-proxy@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/blocking-proxy/-/blocking-proxy-1.0.1.tgz#81d6fd1fe13a4c0d6957df7f91b75e98dac40cb2" - integrity sha512-KE8NFMZr3mN2E0HcvCgRtX7DjhiIQrwle+nSVJVC/yqFb9+xznHl2ZcoBp2L9qzkI4t4cBFJ1efXF8Dwi132RA== - dependencies: - minimist "^1.2.0" - -bluebird@^3.3.0, bluebird@^3.5.1: - version "3.5.2" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.2.tgz#1be0908e054a751754549c270489c1505d4ab15a" - integrity sha512-dhHTWMI7kMx5whMQntl7Vr9C6BvV10lFXDAasnqnrMYhXVCzzk6IO9Fo2L75jXHT07WrOngL1WDXOp+yYS91Yg== - -bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: - version "4.11.8" - resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" - integrity sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA== - -body-parser@1.18.2: - version "1.18.2" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.2.tgz#87678a19d84b47d859b83199bd59bce222b10454" - integrity sha1-h2eKGdhLR9hZuDGZvVm84iKxBFQ= - dependencies: - bytes "3.0.0" - content-type "~1.0.4" - debug "2.6.9" - depd "~1.1.1" - http-errors "~1.6.2" - iconv-lite "0.4.19" - on-finished "~2.3.0" - qs "6.5.1" - raw-body "2.3.2" - type-is "~1.6.15" - -body-parser@^1.16.1: - version "1.18.3" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.3.tgz#5b292198ffdd553b3a0f20ded0592b956955c8b4" - integrity sha1-WykhmP/dVTs6DyDe0FkrlWlVyLQ= - dependencies: - bytes "3.0.0" - content-type "~1.0.4" - debug "2.6.9" - depd "~1.1.2" - http-errors "~1.6.3" - iconv-lite "0.4.23" - on-finished "~2.3.0" - qs "6.5.2" - raw-body "2.3.3" - type-is "~1.6.16" - -bonjour@^3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/bonjour/-/bonjour-3.5.0.tgz#8e890a183d8ee9a2393b3844c691a42bcf7bc9f5" - integrity sha1-jokKGD2O6aI5OzhExpGkK897yfU= - dependencies: - array-flatten "^2.1.0" - deep-equal "^1.0.1" - dns-equal "^1.0.0" - dns-txt "^2.0.2" - multicast-dns "^6.0.1" - multicast-dns-service-types "^1.1.0" - -bootstrap@^4.0.0: - version "4.1.3" - resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.1.3.tgz#0eb371af2c8448e8c210411d0cb824a6409a12be" - integrity sha512-rDFIzgXcof0jDyjNosjv4Sno77X4KuPeFxG2XZZv1/Kc8DRVGVADdoQyyOVDwPqL36DDmtCQbrpMCqvpPLJQ0w== - -boxen@^1.2.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b" - integrity sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw== - dependencies: - ansi-align "^2.0.0" - camelcase "^4.0.0" - chalk "^2.0.1" - cli-boxes "^1.0.0" - string-width "^2.0.0" - term-size "^1.2.0" - widest-line "^2.0.0" - -brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - -braces@^0.1.2: - version "0.1.5" - resolved "https://registry.yarnpkg.com/braces/-/braces-0.1.5.tgz#c085711085291d8b75fdd74eab0f8597280711e6" - integrity sha1-wIVxEIUpHYt1/ddOqw+FlygHEeY= - dependencies: - expand-range "^0.1.0" - -braces@^1.8.2: - version "1.8.5" - resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7" - integrity sha1-uneWLhLf+WnWt2cR6RS3N4V79qc= - dependencies: - expand-range "^1.8.1" - preserve "^0.2.0" - repeat-element "^1.1.2" - -braces@^2.3.0, braces@^2.3.1: - version "2.3.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" - integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== - dependencies: - arr-flatten "^1.1.0" - array-unique "^0.3.2" - extend-shallow "^2.0.1" - fill-range "^4.0.0" - isobject "^3.0.1" - repeat-element "^1.1.2" - snapdragon "^0.8.1" - snapdragon-node "^2.0.1" - split-string "^3.0.2" - to-regex "^3.0.1" - -brfs@^1.4.0: - version "1.6.1" - resolved "https://registry.yarnpkg.com/brfs/-/brfs-1.6.1.tgz#b78ce2336d818e25eea04a0947cba6d4fb8849c3" - integrity sha512-OfZpABRQQf+Xsmju8XE9bDjs+uU4vLREGolP7bDgcpsI17QREyZ4Bl+2KLxxx1kCgA0fAIhKQBaBYh+PEcCqYQ== - dependencies: - quote-stream "^1.0.1" - resolve "^1.1.5" - static-module "^2.2.0" - through2 "^2.0.0" - -brorand@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" - integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= - -browserify-aes@^1.0.0, browserify-aes@^1.0.4: - version "1.2.0" - resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48" - integrity sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA== - dependencies: - buffer-xor "^1.0.3" - cipher-base "^1.0.0" - create-hash "^1.1.0" - evp_bytestokey "^1.0.3" - inherits "^2.0.1" - safe-buffer "^5.0.1" - -browserify-cipher@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/browserify-cipher/-/browserify-cipher-1.0.1.tgz#8d6474c1b870bfdabcd3bcfcc1934a10e94f15f0" - integrity sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w== - dependencies: - browserify-aes "^1.0.4" - browserify-des "^1.0.0" - evp_bytestokey "^1.0.0" - -browserify-des@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/browserify-des/-/browserify-des-1.0.2.tgz#3af4f1f59839403572f1c66204375f7a7f703e9c" - integrity sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A== - dependencies: - cipher-base "^1.0.1" - des.js "^1.0.0" - inherits "^2.0.1" - safe-buffer "^5.1.2" - -browserify-rsa@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524" - integrity sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ= - dependencies: - bn.js "^4.1.0" - randombytes "^2.0.1" - -browserify-sign@^4.0.0: - version "4.0.4" - resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.0.4.tgz#aa4eb68e5d7b658baa6bf6a57e630cbd7a93d298" - integrity sha1-qk62jl17ZYuqa/alfmMMvXqT0pg= - dependencies: - bn.js "^4.1.1" - browserify-rsa "^4.0.0" - create-hash "^1.1.0" - create-hmac "^1.1.2" - elliptic "^6.0.0" - inherits "^2.0.1" - parse-asn1 "^5.0.0" - -browserify-zlib@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.2.0.tgz#2869459d9aa3be245fe8fe2ca1f46e2e7f54d73f" - integrity sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA== - dependencies: - pako "~1.0.5" - -browserslist@^4.0.0, browserslist@^4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.1.1.tgz#328eb4ff1215b12df6589e9ab82f8adaa4fc8cd6" - integrity sha512-VBorw+tgpOtZ1BYhrVSVTzTt/3+vSE3eFUh0N2GCFK1HffceOaf32YS/bs6WiFhjDAblAFrx85jMy3BG9fBK2Q== - dependencies: - caniuse-lite "^1.0.30000884" - electron-to-chromium "^1.3.62" - node-releases "^1.0.0-alpha.11" - -browserstack@^1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/browserstack/-/browserstack-1.5.1.tgz#e2dfa66ffee940ebad0a07f7e00fd4687c455d66" - integrity sha512-O8VMT64P9NOLhuIoD4YngyxBURefaSdR4QdhG8l6HZ9VxtU7jc3m6jLufFwKA5gaf7fetfB2TnRJnMxyob+heg== - dependencies: - https-proxy-agent "^2.2.1" - -buffer-alloc-unsafe@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0" - integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg== - -buffer-alloc@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec" - integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow== - dependencies: - buffer-alloc-unsafe "^1.1.0" - buffer-fill "^1.0.0" - -buffer-equal@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-0.0.1.tgz#91bc74b11ea405bc916bc6aa908faafa5b4aac4b" - integrity sha1-kbx0sR6kBbyRa8aqkI+q+ltKrEs= - -buffer-fill@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" - integrity sha1-+PeLdniYiO858gXNY39o5wISKyw= - -buffer-from@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" - integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== - -buffer-indexof@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/buffer-indexof/-/buffer-indexof-1.1.1.tgz#52fabcc6a606d1a00302802648ef68f639da268c" - integrity sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g== - -buffer-xor@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" - integrity sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk= - -buffer@^4.3.0: - version "4.9.1" - resolved "http://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298" - integrity sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg= - dependencies: - base64-js "^1.0.2" - ieee754 "^1.1.4" - isarray "^1.0.0" - -builtin-modules@^1.0.0, builtin-modules@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" - integrity sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8= - -builtin-modules@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-2.0.0.tgz#60b7ef5ae6546bd7deefa74b08b62a43a232648e" - integrity sha512-3U5kUA5VPsRUA3nofm/BXX7GVHKfxz0hOBAPxXrIvHzlDRkQVqEn6yi8QJegxl4LzOHLdvb7XF5dVawa/VVYBg== - -builtin-status-codes@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" - integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug= - -builtins@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/builtins/-/builtins-1.0.3.tgz#cb94faeb61c8696451db36534e1422f94f0aee88" - integrity sha1-y5T662HIaWRR2zZTThQi+U8K7og= - -bytebuffer@~5: - version "5.0.1" - resolved "https://registry.yarnpkg.com/bytebuffer/-/bytebuffer-5.0.1.tgz#582eea4b1a873b6d020a48d58df85f0bba6cfddd" - integrity sha1-WC7qSxqHO20CCkjVjfhfC7ps/d0= - dependencies: - long "~3" - -bytes@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" - integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg= - -cacache@^10.0.4: - version "10.0.4" - resolved "https://registry.yarnpkg.com/cacache/-/cacache-10.0.4.tgz#6452367999eff9d4188aefd9a14e9d7c6a263460" - integrity sha512-Dph0MzuH+rTQzGPNT9fAnrPmMmjKfST6trxJeK7NQuHRaVw24VzPRWTmg9MpcwOVQZO0E1FBICUlFeNaKPIfHA== - dependencies: - bluebird "^3.5.1" - chownr "^1.0.1" - glob "^7.1.2" - graceful-fs "^4.1.11" - lru-cache "^4.1.1" - mississippi "^2.0.0" - mkdirp "^0.5.1" - move-concurrently "^1.0.1" - promise-inflight "^1.0.1" - rimraf "^2.6.2" - ssri "^5.2.4" - unique-filename "^1.1.0" - y18n "^4.0.0" - -cacache@^11.0.2: - version "11.2.0" - resolved "https://registry.yarnpkg.com/cacache/-/cacache-11.2.0.tgz#617bdc0b02844af56310e411c0878941d5739965" - integrity sha512-IFWl6lfK6wSeYCHUXh+N1lY72UDrpyrYQJNIVQf48paDuWbv5RbAtJYf/4gUQFObTCHZwdZ5sI8Iw7nqwP6nlQ== - dependencies: - bluebird "^3.5.1" - chownr "^1.0.1" - figgy-pudding "^3.1.0" - glob "^7.1.2" - graceful-fs "^4.1.11" - lru-cache "^4.1.3" - mississippi "^3.0.0" - mkdirp "^0.5.1" - move-concurrently "^1.0.1" - promise-inflight "^1.0.1" - rimraf "^2.6.2" - ssri "^6.0.0" - unique-filename "^1.1.0" - y18n "^4.0.0" - -cache-base@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" - integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ== - dependencies: - collection-visit "^1.0.0" - component-emitter "^1.2.1" - get-value "^2.0.6" - has-value "^1.0.0" - isobject "^3.0.1" - set-value "^2.0.0" - to-object-path "^0.3.0" - union-value "^1.0.0" - unset-value "^1.0.0" - -cacheable-request@^2.1.1: - version "2.1.4" - resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-2.1.4.tgz#0d808801b6342ad33c91df9d0b44dc09b91e5c3d" - integrity sha1-DYCIAbY0KtM8kd+dC0TcCbkeXD0= - dependencies: - clone-response "1.0.2" - get-stream "3.0.0" - http-cache-semantics "3.8.1" - keyv "3.0.0" - lowercase-keys "1.0.0" - normalize-url "2.0.1" - responselike "1.0.2" - -callsite@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20" - integrity sha1-KAOY5dZkvXQDi28JBRU+borxvCA= - -camelcase-keys@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7" - integrity sha1-MIvur/3ygRkFHvodkyITyRuPkuc= - dependencies: - camelcase "^2.0.0" - map-obj "^1.0.0" - -camelcase-keys@^4.0.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-4.2.0.tgz#a2aa5fb1af688758259c32c141426d78923b9b77" - integrity sha1-oqpfsa9oh1glnDLBQUJteJI7m3c= - dependencies: - camelcase "^4.1.0" - map-obj "^2.0.0" - quick-lru "^1.0.0" - -camelcase@^2.0.0, camelcase@^2.0.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f" - integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8= - -camelcase@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a" - integrity sha1-MvxLn82vhF/N9+c7uXysImHwqwo= - -camelcase@^4.0.0, camelcase@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" - integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0= - -caniuse-lite@^1.0.30000884: - version "1.0.30000885" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000885.tgz#e889e9f8e7e50e769f2a49634c932b8aee622984" - integrity sha512-cXKbYwpxBLd7qHyej16JazPoUacqoVuDhvR61U7Fr5vSxMUiodzcYa1rQYRYfZ5GexV03vGZHd722vNPLjPJGQ== - -canonical-path@0.0.2: - version "0.0.2" - resolved "https://registry.yarnpkg.com/canonical-path/-/canonical-path-0.0.2.tgz#e31eb937a8c93ee2a01df1839794721902874574" - integrity sha1-4x65N6jJPuKgHfGDl5RyGQKHRXQ= - -capture-stack-trace@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz#a6c0bbe1f38f3aa0b92238ecb6ff42c344d4135d" - integrity sha512-mYQLZnx5Qt1JgB1WEiMCf2647plpGeQ2NMR/5L0HNZzGQo4fuSPnK+wjfPnKZV0aiJDgzmWqqkV/g7JD+DW0qw== - -caseless@~0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" - integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= - -chalk@^1.1.1, chalk@^1.1.3: - version "1.1.3" - resolved "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" - integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg= - dependencies: - ansi-styles "^2.2.1" - escape-string-regexp "^1.0.2" - has-ansi "^2.0.0" - strip-ansi "^3.0.0" - supports-color "^2.0.0" - -chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.3.1, chalk@^2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e" - integrity sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -chardet@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" - integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== - -chokidar@2.0.4, chokidar@^2.0.0, chokidar@^2.0.2, chokidar@^2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.0.4.tgz#356ff4e2b0e8e43e322d18a372460bbcf3accd26" - integrity sha512-z9n7yt9rOvIJrMhvDtDictKrkFHeihkNl6uWMmZlmL6tJtX9Cs+87oK+teBx+JIgzvbX3yZHT3eF8vpbDxHJXQ== - dependencies: - anymatch "^2.0.0" - async-each "^1.0.0" - braces "^2.3.0" - glob-parent "^3.1.0" - inherits "^2.0.1" - is-binary-path "^1.0.0" - is-glob "^4.0.0" - lodash.debounce "^4.0.8" - normalize-path "^2.1.1" - path-is-absolute "^1.0.0" - readdirp "^2.0.0" - upath "^1.0.5" - optionalDependencies: - fsevents "^1.2.2" - -chokidar@^1.4.2: - version "1.7.0" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468" - integrity sha1-eY5ol3gVHIB2tLNg5e3SjNortGg= - dependencies: - anymatch "^1.3.0" - async-each "^1.0.0" - glob-parent "^2.0.0" - inherits "^2.0.1" - is-binary-path "^1.0.0" - is-glob "^2.0.0" - path-is-absolute "^1.0.0" - readdirp "^2.0.0" - optionalDependencies: - fsevents "^1.0.0" - -chownr@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.0.1.tgz#e2a75042a9551908bebd25b8523d5f9769d79181" - integrity sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE= - -chrome-trace-event@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.0.tgz#45a91bd2c20c9411f0963b5aaeb9a1b95e09cc48" - integrity sha512-xDbVgyfDTT2piup/h8dK/y4QZfJRSa73bw1WZ8b4XM1o7fsFubUVGYcE+1ANtOzJJELGpYoG2961z0Z6OAld9A== - dependencies: - tslib "^1.9.0" - -ci-info@^1.5.0: - version "1.5.1" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.5.1.tgz#17e8eb5de6f8b2b6038f0cbb714d410bfa9f3030" - integrity sha512-fKFIKXaYiL1exImwJ0AhR/6jxFPSKQBk2ayV5NiNoruUs2+rxC2kNw0EG+1Z9dugZRdCrppskQ8DN2cyaUM1Hw== - -cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" - integrity sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q== - dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" - -circular-dependency-plugin@5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/circular-dependency-plugin/-/circular-dependency-plugin-5.0.2.tgz#da168c0b37e7b43563fb9f912c1c007c213389ef" - integrity sha512-oC7/DVAyfcY3UWKm0sN/oVoDedQDQiw/vIiAnuTWTpE5s0zWf7l3WY417Xw/Fbi/QbAjctAkxgMiS9P0s3zkmA== - -circular-json@^0.5.5: - version "0.5.5" - resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.5.5.tgz#64182ef359042d37cd8e767fc9de878b1e9447d3" - integrity sha512-13YaR6kiz0kBNmIVM87Io8Hp7bWOo4r61vkEANy8iH9R9bc6avud/1FT0SBpqR1RpIQADOh/Q+yHZDA1iL6ysA== - -class-utils@^0.3.5: - version "0.3.6" - resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" - integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg== - dependencies: - arr-union "^3.1.0" - define-property "^0.2.5" - isobject "^3.0.0" - static-extend "^0.1.1" - -clean-css@4.2.1, clean-css@^4.1.11: - version "4.2.1" - resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.1.tgz#2d411ef76b8569b6d0c84068dabe85b0aa5e5c17" - integrity sha512-4ZxI6dy4lrY6FHzfiy1aEOXgu4LIsW2MhwG0VBKdcoGoH/XLFgaHSdLTGr4O8Be6A8r3MOphEiI8Gc1n0ecf3g== - dependencies: - source-map "~0.6.0" - -cli-boxes@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143" - integrity sha1-T6kXw+WclKAEzWH47lCdplFocUM= - -cli-cursor@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" - integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU= - dependencies: - restore-cursor "^2.0.0" - -cli-width@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" - integrity sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk= - -cliui@^3.0.3, cliui@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d" - integrity sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0= - dependencies: - string-width "^1.0.1" - strip-ansi "^3.0.1" - wrap-ansi "^2.0.0" - -cliui@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-4.1.0.tgz#348422dbe82d800b3022eef4f6ac10bf2e4d1b49" - integrity sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ== - dependencies: - string-width "^2.1.1" - strip-ansi "^4.0.0" - wrap-ansi "^2.0.0" - -clone-deep@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-2.0.2.tgz#00db3a1e173656730d1188c3d6aced6d7ea97713" - integrity sha512-SZegPTKjCgpQH63E+eN6mVEEPdQBOUzjyJm5Pora4lrwWRFS8I0QAxV/KD6vV/i0WuijHZWQC1fMsPEdxfdVCQ== - dependencies: - for-own "^1.0.0" - is-plain-object "^2.0.4" - kind-of "^6.0.0" - shallow-clone "^1.0.0" - -clone-response@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b" - integrity sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws= - dependencies: - mimic-response "^1.0.0" - -clone@^2.1.1, clone@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" - integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18= - -co@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" - integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= - -code-point-at@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" - integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= - -codelyzer@^4.2.1: - version "4.4.4" - resolved "https://registry.yarnpkg.com/codelyzer/-/codelyzer-4.4.4.tgz#29b7dbb51ba9ecc45c7300d61280a6564765d402" - integrity sha512-JgFMudx0n50IuE/ydAfnkksCwQkWSVWgYvhDPHZgDUbmsiYC22VuEXKu5l8Hhx9UJsLgjWDLjTAFGj2WaW5DUA== - dependencies: - app-root-path "^2.1.0" - css-selector-tokenizer "^0.7.0" - cssauron "^1.4.0" - semver-dsl "^1.0.1" - source-map "^0.5.7" - sprintf-js "^1.1.1" - -collection-utils@0.0.15: - version "0.0.15" - resolved "https://registry.yarnpkg.com/collection-utils/-/collection-utils-0.0.15.tgz#70f4c54299621699b3f38c66577c9d4284825125" - integrity sha512-eLGXa67rSilE5dNCcx2wenWcmmgl4QdlwUj1Z5idKkIBOhZh6PghEZFC3wP3AHH8xnMbIDsky0pR/+1epJBymQ== - -collection-visit@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" - integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA= - dependencies: - map-visit "^1.0.0" - object-visit "^1.0.0" - -color-convert@^1.9.0, color-convert@^1.9.1: - version "1.9.3" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" - integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== - dependencies: - color-name "1.1.3" - -color-name@1.1.3, color-name@^1.0.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= - -color-string@^1.5.2: - version "1.5.3" - resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc" - integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw== - dependencies: - color-name "^1.0.0" - simple-swizzle "^0.2.2" - -color@3.0.x: - version "3.0.0" - resolved "https://registry.yarnpkg.com/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a" - integrity sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w== - dependencies: - color-convert "^1.9.1" - color-string "^1.5.2" - -colornames@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/colornames/-/colornames-1.1.1.tgz#f8889030685c7c4ff9e2a559f5077eb76a816f96" - integrity sha1-+IiQMGhcfE/54qVZ9Qd+t2qBb5Y= - -colors@1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" - integrity sha1-FopHAXVran9RoSzgyXv6KMCE7WM= - -colors@^1.1.0, colors@^1.2.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/colors/-/colors-1.3.2.tgz#2df8ff573dfbf255af562f8ce7181d6b971a359b" - integrity sha512-rhP0JSBGYvpcNQj4s5AdShMeE5ahMop96cTeDl/v9qQQm2fYClE2QXZRi8wLzc+GmXSxdIqqbOIAhyObEXDbfQ== - -colorspace@1.1.x: - version "1.1.1" - resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.1.tgz#9ac2491e1bc6f8fb690e2176814f8d091636d972" - integrity sha512-pI3btWyiuz7Ken0BWh9Elzsmv2bM9AhA7psXib4anUXy/orfZ/E0MbQwhSOG/9L8hLlalqrU0UhOuqxW1YjmVw== - dependencies: - color "3.0.x" - text-hex "1.0.x" - -colour@~0.7.1: - version "0.7.1" - resolved "https://registry.yarnpkg.com/colour/-/colour-0.7.1.tgz#9cb169917ec5d12c0736d3e8685746df1cadf778" - integrity sha1-nLFpkX7F0SwHNtPoaFdG3xyt93g= - -combine-lists@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/combine-lists/-/combine-lists-1.0.1.tgz#458c07e09e0d900fc28b70a3fec2dacd1d2cb7f6" - integrity sha1-RYwH4J4NkA/Ci3Cj/sLazR0st/Y= - dependencies: - lodash "^4.5.0" - -combined-stream@1.0.6, combined-stream@~1.0.5, combined-stream@~1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.6.tgz#723e7df6e801ac5613113a7e445a9b69cb632818" - integrity sha1-cj599ugBrFYTETp+RFqbactjKBg= - dependencies: - delayed-stream "~1.0.0" - -commander@^2.12.0, commander@^2.12.1: - version "2.18.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.18.0.tgz#2bf063ddee7c7891176981a2cc798e5754bc6970" - integrity sha512-6CYPa+JP2ftfRU2qkDK+UTVeQYosOg/2GbcjIcKPHfinyOLPVGXu/ovN86RP49Re5ndJK1N0kuiidFFuepc4ZQ== - -commander@~2.13.0: - version "2.13.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.13.0.tgz#6964bca67685df7c1f1430c584f07d7597885b9c" - integrity sha512-MVuS359B+YzaWqjCL/c+22gfryv+mCBPHAv3zyVI2GN8EY6IRP8VwtasXn8jyyhvvq84R4ImN1OKRtcbIasjYA== - -commander@~2.17.1: - version "2.17.1" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf" - integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg== - -common-tags@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937" - integrity sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw== - -commondir@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" - integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= - -compare-func@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/compare-func/-/compare-func-1.3.2.tgz#99dd0ba457e1f9bc722b12c08ec33eeab31fa648" - integrity sha1-md0LpFfh+bxyKxLAjsM+6rMfpkg= - dependencies: - array-ify "^1.0.0" - dot-prop "^3.0.0" - -compare-versions@^3.2.1: - version "3.4.0" - resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.4.0.tgz#e0747df5c9cb7f054d6d3dc3e1dbc444f9e92b26" - integrity sha512-tK69D7oNXXqUW3ZNo/z7NXTEz22TCF0pTE+YF9cxvaAM9XnkLo1fV621xCLrRR6aevJlKxExkss0vWqUCUpqdg== - -component-bind@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1" - integrity sha1-AMYIq33Nk4l8AAllGx06jh5zu9E= - -component-emitter@1.2.1, component-emitter@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" - integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY= - -component-inherit@0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143" - integrity sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM= - -compressible@~2.0.14: - version "2.0.14" - resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.14.tgz#326c5f507fbb055f54116782b969a81b67a29da7" - integrity sha1-MmxfUH+7BV9UEWeCuWmoG2einac= - dependencies: - mime-db ">= 1.34.0 < 2" - -compression@^1.5.2: - version "1.7.3" - resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.3.tgz#27e0e176aaf260f7f2c2813c3e440adb9f1993db" - integrity sha512-HSjyBG5N1Nnz7tF2+O7A9XUhyjru71/fwgNb7oIsEVHR0WShfs2tIS/EySLgiTe98aOK18YDlMXpzjCXY/n9mg== - dependencies: - accepts "~1.3.5" - bytes "3.0.0" - compressible "~2.0.14" - debug "2.6.9" - on-headers "~1.0.1" - safe-buffer "5.1.2" - vary "~1.1.2" - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= - -concat-stream@^1.5.0, concat-stream@^1.5.2, concat-stream@~1.6.0: - version "1.6.2" - resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" - integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== - dependencies: - buffer-from "^1.0.0" - inherits "^2.0.3" - readable-stream "^2.2.2" - typedarray "^0.0.6" - -configstore@^3.0.0: - version "3.1.2" - resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.2.tgz#c6f25defaeef26df12dd33414b001fe81a543f8f" - integrity sha512-vtv5HtGjcYUgFrXc6Kx747B83MRRVS5R1VTEQoXvuP+kMI+if6uywV0nDGoiydJRy4yk7h9od5Og0kxx4zUXmw== - dependencies: - dot-prop "^4.1.0" - graceful-fs "^4.1.2" - make-dir "^1.0.0" - unique-string "^1.0.0" - write-file-atomic "^2.0.0" - xdg-basedir "^3.0.0" - -connect-history-api-fallback@^1.3.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.5.0.tgz#b06873934bc5e344fef611a196a6faae0aee015a" - integrity sha1-sGhzk0vF40T+9hGhlqb6rgruAVo= - -connect@^3.6.0: - version "3.6.6" - resolved "https://registry.yarnpkg.com/connect/-/connect-3.6.6.tgz#09eff6c55af7236e137135a72574858b6786f524" - integrity sha1-Ce/2xVr3I24TcTWnJXSFi2eG9SQ= - dependencies: - debug "2.6.9" - finalhandler "1.1.0" - parseurl "~1.3.2" - utils-merge "1.0.1" - -console-browserify@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.1.0.tgz#f0241c45730a9fc6323b206dbf38edc741d0bb10" - integrity sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA= - dependencies: - date-now "^0.1.4" - -console-control-strings@^1.0.0, console-control-strings@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" - integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= - -constants-browserify@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" - integrity sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U= - -content-disposition@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" - integrity sha1-DPaLud318r55YcOoUXjLhdunjLQ= - -content-type@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" - integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== - -conventional-changelog-angular@^1.6.6: - version "1.6.6" - resolved "https://registry.yarnpkg.com/conventional-changelog-angular/-/conventional-changelog-angular-1.6.6.tgz#b27f2b315c16d0a1f23eb181309d0e6a4698ea0f" - integrity sha512-suQnFSqCxRwyBxY68pYTsFkG0taIdinHLNEAX5ivtw8bCRnIgnpvcHmlR/yjUyZIrNPYAoXlY1WiEKWgSE4BNg== - dependencies: - compare-func "^1.3.1" - q "^1.5.1" - -conventional-changelog-atom@^0.2.8: - version "0.2.8" - resolved "https://registry.yarnpkg.com/conventional-changelog-atom/-/conventional-changelog-atom-0.2.8.tgz#8037693455990e3256f297320a45fa47ee553a14" - integrity sha512-8pPZqhMbrnltNBizjoDCb/Sz85KyUXNDQxuAEYAU5V/eHn0okMBVjqc8aHWYpHrytyZWvMGbayOlDv7i8kEf6g== - dependencies: - q "^1.5.1" - -conventional-changelog-codemirror@^0.3.8: - version "0.3.8" - resolved "https://registry.yarnpkg.com/conventional-changelog-codemirror/-/conventional-changelog-codemirror-0.3.8.tgz#a1982c8291f4ee4d6f2f62817c6b2ecd2c4b7b47" - integrity sha512-3HFZKtBXTaUCHvz7ai6nk2+psRIkldDoNzCsom0egDtVmPsvvHZkzjynhdQyULfacRSsBTaiQ0ol6nBOL4dDiQ== - dependencies: - q "^1.5.1" - -conventional-changelog-core@^2.0.11: - version "2.0.11" - resolved "https://registry.yarnpkg.com/conventional-changelog-core/-/conventional-changelog-core-2.0.11.tgz#19b5fbd55a9697773ed6661f4e32030ed7e30287" - integrity sha512-HvTE6RlqeEZ/NFPtQeFLsIDOLrGP3bXYr7lFLMhCVsbduF1MXIe8OODkwMFyo1i9ku9NWBwVnVn0jDmIFXjDRg== - dependencies: - conventional-changelog-writer "^3.0.9" - conventional-commits-parser "^2.1.7" - dateformat "^3.0.0" - get-pkg-repo "^1.0.0" - git-raw-commits "^1.3.6" - git-remote-origin-url "^2.0.0" - git-semver-tags "^1.3.6" - lodash "^4.2.1" - normalize-package-data "^2.3.5" - q "^1.5.1" - read-pkg "^1.1.0" - read-pkg-up "^1.0.1" - through2 "^2.0.0" - -conventional-changelog-ember@^0.3.12: - version "0.3.12" - resolved "https://registry.yarnpkg.com/conventional-changelog-ember/-/conventional-changelog-ember-0.3.12.tgz#b7d31851756d0fcb49b031dffeb6afa93b202400" - integrity sha512-mmJzA7uzbrOqeF89dMMi6z17O07ORTXlTMArnLG9ZTX4oLaKNolUlxFUFlFm9JUoVWajVpaHQWjxH1EOQ+ARoQ== - dependencies: - q "^1.5.1" - -conventional-changelog-eslint@^1.0.9: - version "1.0.9" - resolved "https://registry.yarnpkg.com/conventional-changelog-eslint/-/conventional-changelog-eslint-1.0.9.tgz#b13cc7e4b472c819450ede031ff1a75c0e3d07d3" - integrity sha512-h87nfVh2fdk9fJIvz26wCBsbDC/KxqCc5wSlNMZbXcARtbgNbNDIF7Y7ctokFdnxkzVdaHsbINkh548T9eBA7Q== - dependencies: - q "^1.5.1" - -conventional-changelog-express@^0.3.6: - version "0.3.6" - resolved "https://registry.yarnpkg.com/conventional-changelog-express/-/conventional-changelog-express-0.3.6.tgz#4a6295cb11785059fb09202180d0e59c358b9c2c" - integrity sha512-3iWVtBJZ9RnRnZveNDzOD8QRn6g6vUif0qVTWWyi5nUIAbuN1FfPVyKdAlJJfp5Im+dE8Kiy/d2SpaX/0X678Q== - dependencies: - q "^1.5.1" - -conventional-changelog-jquery@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-jquery/-/conventional-changelog-jquery-0.1.0.tgz#0208397162e3846986e71273b6c79c5b5f80f510" - integrity sha1-Agg5cWLjhGmG5xJztsecW1+A9RA= - dependencies: - q "^1.4.1" - -conventional-changelog-jscs@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/conventional-changelog-jscs/-/conventional-changelog-jscs-0.1.0.tgz#0479eb443cc7d72c58bf0bcf0ef1d444a92f0e5c" - integrity sha1-BHnrRDzH1yxYvwvPDvHURKkvDlw= - dependencies: - q "^1.4.1" - -conventional-changelog-jshint@^0.3.8: - version "0.3.8" - resolved "https://registry.yarnpkg.com/conventional-changelog-jshint/-/conventional-changelog-jshint-0.3.8.tgz#9051c1ac0767abaf62a31f74d2fe8790e8acc6c8" - integrity sha512-hn9QU4ZI/5V50wKPJNPGT4gEWgiBFpV6adieILW4MaUFynuDYOvQ71EMSj3EznJyKi/KzuXpc9dGmX8njZMjig== - dependencies: - compare-func "^1.3.1" - q "^1.5.1" - -conventional-changelog-preset-loader@^1.1.8: - version "1.1.8" - resolved "https://registry.yarnpkg.com/conventional-changelog-preset-loader/-/conventional-changelog-preset-loader-1.1.8.tgz#40bb0f142cd27d16839ec6c74ee8db418099b373" - integrity sha512-MkksM4G4YdrMlT2MbTsV2F6LXu/hZR0Tc/yenRrDIKRwBl/SP7ER4ZDlglqJsCzLJi4UonBc52Bkm5hzrOVCcw== - -conventional-changelog-writer@^3.0.9: - version "3.0.9" - resolved "https://registry.yarnpkg.com/conventional-changelog-writer/-/conventional-changelog-writer-3.0.9.tgz#4aecdfef33ff2a53bb0cf3b8071ce21f0e994634" - integrity sha512-n9KbsxlJxRQsUnK6wIBRnARacvNnN4C/nxnxCkH+B/R1JS2Fa+DiP1dU4I59mEDEjgnFaN2+9wr1P1s7GYB5/Q== - dependencies: - compare-func "^1.3.1" - conventional-commits-filter "^1.1.6" - dateformat "^3.0.0" - handlebars "^4.0.2" - json-stringify-safe "^5.0.1" - lodash "^4.2.1" - meow "^4.0.0" - semver "^5.5.0" - split "^1.0.0" - through2 "^2.0.0" - -conventional-changelog@^1.1.0: - version "1.1.24" - resolved "https://registry.yarnpkg.com/conventional-changelog/-/conventional-changelog-1.1.24.tgz#3d94c29c960f5261c002678315b756cdd3d7d1f0" - integrity sha512-2WcSUst4Y3Z4hHvoMTWXMJr/DmgVdLiMOVY1Kak2LfFz+GIz2KDp5naqbFesYbfXPmaZ5p491dO0FWZIJoJw1Q== - dependencies: - conventional-changelog-angular "^1.6.6" - conventional-changelog-atom "^0.2.8" - conventional-changelog-codemirror "^0.3.8" - conventional-changelog-core "^2.0.11" - conventional-changelog-ember "^0.3.12" - conventional-changelog-eslint "^1.0.9" - conventional-changelog-express "^0.3.6" - conventional-changelog-jquery "^0.1.0" - conventional-changelog-jscs "^0.1.0" - conventional-changelog-jshint "^0.3.8" - conventional-changelog-preset-loader "^1.1.8" - -conventional-commits-filter@^1.1.6: - version "1.1.6" - resolved "https://registry.yarnpkg.com/conventional-commits-filter/-/conventional-commits-filter-1.1.6.tgz#4389cd8e58fe89750c0b5fb58f1d7f0cc8ad3831" - integrity sha512-KcDgtCRKJCQhyk6VLT7zR+ZOyCnerfemE/CsR3iQpzRRFbLEs0Y6rwk3mpDvtOh04X223z+1xyJ582Stfct/0Q== - dependencies: - is-subset "^0.1.1" - modify-values "^1.0.0" - -conventional-commits-parser@^2.1.7: - version "2.1.7" - resolved "https://registry.yarnpkg.com/conventional-commits-parser/-/conventional-commits-parser-2.1.7.tgz#eca45ed6140d72ba9722ee4132674d639e644e8e" - integrity sha512-BoMaddIEJ6B4QVMSDu9IkVImlGOSGA1I2BQyOZHeLQ6qVOJLcLKn97+fL6dGbzWEiqDzfH4OkcveULmeq2MHFQ== - dependencies: - JSONStream "^1.0.4" - is-text-path "^1.0.0" - lodash "^4.2.1" - meow "^4.0.0" - split2 "^2.0.0" - through2 "^2.0.0" - trim-off-newlines "^1.0.0" - -conventional-commits-parser@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/conventional-commits-parser/-/conventional-commits-parser-3.0.0.tgz#7f604549a50bd8f60443fbe515484b1c2f06a5c4" - integrity sha512-GWh71U26BLWgMykCp+VghZ4s64wVbtseECcKQ/PvcPZR2cUnz+FUc2J9KjxNl7/ZbCxST8R03c9fc+Vi0umS9Q== - dependencies: - JSONStream "^1.0.4" - is-text-path "^1.0.0" - lodash "^4.2.1" - meow "^4.0.0" - split2 "^2.0.0" - through2 "^2.0.0" - trim-off-newlines "^1.0.0" - -convert-source-map@^1.5.0, convert-source-map@^1.5.1: - version "1.6.0" - resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" - integrity sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A== - dependencies: - safe-buffer "~5.1.1" - -cookie-signature@1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" - integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= - -cookie@0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" - integrity sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s= - -copy-concurrently@^1.0.0: - version "1.0.5" - resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0" - integrity sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A== - dependencies: - aproba "^1.1.1" - fs-write-stream-atomic "^1.0.8" - iferr "^0.1.5" - mkdirp "^0.5.1" - rimraf "^2.5.4" - run-queue "^1.0.0" - -copy-descriptor@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" - integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= - -copy-webpack-plugin@4.5.4: - version "4.5.4" - resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-4.5.4.tgz#f2b2782b3cd5225535c3dc166a80067e7d940f27" - integrity sha512-0lstlEyj74OAtYMrDxlNZsU7cwFijAI3Ofz2fD6Mpo9r4xCv4yegfa3uHIKvZY1NSuOtE9nvG6TAhJ+uz9gDaQ== - dependencies: - cacache "^10.0.4" - find-cache-dir "^1.0.0" - globby "^7.1.1" - is-glob "^4.0.0" - loader-utils "^1.1.0" - minimatch "^3.0.4" - p-limit "^1.0.0" - serialize-javascript "^1.4.0" - -core-js@^2.2.0, core-js@^2.4.0, core-js@^2.4.1: - version "2.5.7" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e" - integrity sha512-RszJCAxg/PP6uzXVXL6BsxSXx/B05oJAQ2vkJRjyjrEcNVycaqOmNb5OTxZPE3xa5gwZduqza6L9JOCenh/Ecw== - -core-js@~2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.3.0.tgz#fab83fbb0b2d8dc85fa636c4b9d34c75420c6d65" - integrity sha1-+rg/uwstjchfpjbEudNMdUIMbWU= - -core-util-is@1.0.2, core-util-is@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" - integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= - -cosmiconfig@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-4.0.0.tgz#760391549580bbd2df1e562bc177b13c290972dc" - integrity sha512-6e5vDdrXZD+t5v0L8CrurPeybg4Fmf+FCSYxXKYVAqLUtyCSbuyqE059d0kDthTNRzKVjL7QMgNpEUlsoYH3iQ== - dependencies: - is-directory "^0.3.1" - js-yaml "^3.9.0" - parse-json "^4.0.0" - require-from-string "^2.0.1" - -create-ecdh@^4.0.0: - version "4.0.3" - resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff" - integrity sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw== - dependencies: - bn.js "^4.1.0" - elliptic "^6.0.0" - -create-error-class@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/create-error-class/-/create-error-class-3.0.2.tgz#06be7abef947a3f14a30fd610671d401bca8b7b6" - integrity sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y= - dependencies: - capture-stack-trace "^1.0.0" - -create-hash@^1.1.0, create-hash@^1.1.2: - version "1.2.0" - resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" - integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== - dependencies: - cipher-base "^1.0.1" - inherits "^2.0.1" - md5.js "^1.3.4" - ripemd160 "^2.0.1" - sha.js "^2.4.0" - -create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: - version "1.1.7" - resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" - integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== - dependencies: - cipher-base "^1.0.3" - create-hash "^1.1.0" - inherits "^2.0.1" - ripemd160 "^2.0.0" - safe-buffer "^5.0.1" - sha.js "^2.4.8" - -cross-spawn@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-3.0.1.tgz#1256037ecb9f0c5f79e3d6ef135e30770184b982" - integrity sha1-ElYDfsufDF9549bvE14wdwGEuYI= - dependencies: - lru-cache "^4.0.1" - which "^1.2.9" - -cross-spawn@^5.0.1: - version "5.1.0" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" - integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk= - dependencies: - lru-cache "^4.0.1" - shebang-command "^1.2.0" - which "^1.2.9" - -cross-spawn@^6.0.0: - version "6.0.5" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" - integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== - dependencies: - nice-try "^1.0.4" - path-key "^2.0.1" - semver "^5.5.0" - shebang-command "^1.2.0" - which "^1.2.9" - -crypto-browserify@^3.11.0: - version "3.12.0" - resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec" - integrity sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg== - dependencies: - browserify-cipher "^1.0.0" - browserify-sign "^4.0.0" - create-ecdh "^4.0.0" - create-hash "^1.1.0" - create-hmac "^1.1.0" - diffie-hellman "^5.0.0" - inherits "^2.0.1" - pbkdf2 "^3.0.3" - public-encrypt "^4.0.0" - randombytes "^2.0.0" - randomfill "^1.0.3" - -crypto-random-string@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e" - integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4= - -css-parse@1.7.x: - version "1.7.0" - resolved "https://registry.yarnpkg.com/css-parse/-/css-parse-1.7.0.tgz#321f6cf73782a6ff751111390fc05e2c657d8c9b" - integrity sha1-Mh9s9zeCpv91ERE5D8BeLGV9jJs= - -css-selector-tokenizer@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/css-selector-tokenizer/-/css-selector-tokenizer-0.7.0.tgz#e6988474ae8c953477bf5e7efecfceccd9cf4c86" - integrity sha1-5piEdK6MlTR3v15+/s/OzNnPTIY= - dependencies: - cssesc "^0.1.0" - fastparse "^1.1.1" - regexpu-core "^1.0.0" - -cssauron@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/cssauron/-/cssauron-1.4.0.tgz#a6602dff7e04a8306dc0db9a551e92e8b5662ad8" - integrity sha1-pmAt/34EqDBtwNuaVR6S6LVmKtg= - dependencies: - through X.X.X - -cssesc@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-0.1.0.tgz#c814903e45623371a0477b40109aaafbeeaddbb4" - integrity sha1-yBSQPkViM3GgR3tAEJqq++6t27Q= - -cuint@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b" - integrity sha1-QICG1AlVDCYxFVYZ6fp7ytw7mRs= - -currently-unhandled@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" - integrity sha1-mI3zP+qxke95mmE2nddsF635V+o= - dependencies: - array-find-index "^1.0.1" - -custom-event@~1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425" - integrity sha1-XQKkaFCt8bSjF5RqOSj8y1v9BCU= - -cyclist@~0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640" - integrity sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA= - -dargs@^4.0.1: - version "4.1.0" - resolved "https://registry.yarnpkg.com/dargs/-/dargs-4.1.0.tgz#03a9dbb4b5c2f139bf14ae53f0b8a2a6a86f4e17" - integrity sha1-A6nbtLXC8Tm/FK5T8LiipqhvThc= - dependencies: - number-is-nan "^1.0.0" - -dashdash@^1.12.0: - version "1.14.1" - resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" - integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= - dependencies: - assert-plus "^1.0.0" - -date-format@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/date-format/-/date-format-1.2.0.tgz#615e828e233dd1ab9bb9ae0950e0ceccfa6ecad8" - integrity sha1-YV6CjiM90aubua4JUODOzPpuytg= - -date-now@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" - integrity sha1-6vQ5/U1ISK105cx9vvIAZyueNFs= - -dateformat@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" - integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== - -debug@*: - version "4.0.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.0.0.tgz#ef7592a30b1c5e05a78e96926df2e6b36b5544e7" - integrity sha512-PlYAp+yaKUjcs6FIDv1G2kU9jh4+OOD7AniwnWEvdoeHSsi5X6vRNuI9MDZCl8YcF/aNsvuF5EDOjY/v90zdrg== - dependencies: - ms "^2.1.1" - -debug@2.6.9, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.6, debug@^2.6.8: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - -debug@^3.1.0: - version "3.2.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.4.tgz#82123737c51afbe9609a2b5dfe9664e7487171f0" - integrity sha512-fCEG5fOr7m/fhgOD3KurdAov706JbXZJYXAsAOEJ7GgasGr0GO4N+1NsIcrjlIUcyvJ9oZlnelTzN3Ix8z1ecw== - dependencies: - ms "^2.1.1" - -debug@^3.2.5: - version "3.2.6" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" - integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== - dependencies: - ms "^2.1.1" - -debug@~3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" - integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== - dependencies: - ms "2.0.0" - -debuglog@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" - integrity sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI= - -decamelize-keys@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.0.tgz#d171a87933252807eb3cb61dc1c1445d078df2d9" - integrity sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk= - dependencies: - decamelize "^1.1.0" - map-obj "^1.0.0" - -decamelize@^1.1.0, decamelize@^1.1.1, decamelize@^1.1.2: - version "1.2.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= - -decamelize@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-2.0.0.tgz#656d7bbc8094c4c788ea53c5840908c9c7d063c7" - integrity sha512-Ikpp5scV3MSYxY39ymh45ZLEecsTdv/Xj2CaQfI8RLMuwi7XvjX9H/fhraiSuU+C5w5NTDu4ZU72xNiZnurBPg== - dependencies: - xregexp "4.0.0" - -decode-uri-component@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" - integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= - -decompress-response@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" - integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M= - dependencies: - mimic-response "^1.0.0" - -deep-equal@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" - integrity sha1-9dJgKStmDghO/0zbyfCK0yR0SLU= - -deep-extend@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" - integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== - -deep-is@~0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" - integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= - -default-gateway@^2.6.0: - version "2.7.2" - resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-2.7.2.tgz#b7ef339e5e024b045467af403d50348db4642d0f" - integrity sha512-lAc4i9QJR0YHSDFdzeBQKfZ1SRDG3hsJNEkrpcZa8QhBfidLAilT60BDEIVUUGqosFp425KOgB3uYqcnQrWafQ== - dependencies: - execa "^0.10.0" - ip-regex "^2.1.0" - -default-require-extensions@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/default-require-extensions/-/default-require-extensions-2.0.0.tgz#f5f8fbb18a7d6d50b21f641f649ebb522cfe24f7" - integrity sha1-9fj7sYp9bVCyH2QfZJ67Uiz+JPc= - dependencies: - strip-bom "^3.0.0" - -define-property@^0.2.5: - version "0.2.5" - resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" - integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY= - dependencies: - is-descriptor "^0.1.0" - -define-property@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" - integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY= - dependencies: - is-descriptor "^1.0.0" - -define-property@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" - integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ== - dependencies: - is-descriptor "^1.0.2" - isobject "^3.0.1" - -del@^2.2.0: - version "2.2.2" - resolved "https://registry.yarnpkg.com/del/-/del-2.2.2.tgz#c12c981d067846c84bcaf862cff930d907ffd1a8" - integrity sha1-wSyYHQZ4RshLyvhiz/kw2Qf/0ag= - dependencies: - globby "^5.0.0" - is-path-cwd "^1.0.0" - is-path-in-cwd "^1.0.0" - object-assign "^4.0.1" - pify "^2.0.0" - pinkie-promise "^2.0.0" - rimraf "^2.2.8" - -del@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/del/-/del-3.0.0.tgz#53ecf699ffcbcb39637691ab13baf160819766e5" - integrity sha1-U+z2mf/LyzljdpGrE7rxYIGXZuU= - dependencies: - globby "^6.1.0" - is-path-cwd "^1.0.0" - is-path-in-cwd "^1.0.0" - p-map "^1.1.1" - pify "^3.0.0" - rimraf "^2.2.8" - -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= - -delegates@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" - integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= - -depd@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359" - integrity sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k= - -depd@~1.1.1, depd@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" - integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= - -dependency-graph@^0.7.2: - version "0.7.2" - resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-0.7.2.tgz#91db9de6eb72699209d88aea4c1fd5221cac1c49" - integrity sha512-KqtH4/EZdtdfWX0p6MGP9jljvxSY6msy/pRUD4jgNwVpv3v1QmNLlsB3LDSSUg79BRVSn7jI1QPRtArGABovAQ== - -des.js@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.0.tgz#c074d2e2aa6a8a9a07dbd61f9a15c2cd83ec8ecc" - integrity sha1-wHTS4qpqipoH29YfmhXCzYPsjsw= - dependencies: - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - -destroy@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" - integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= - -detect-indent@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208" - integrity sha1-920GQ1LN9Docts5hnE7jqUdd4gg= - dependencies: - repeating "^2.0.0" - -detect-libc@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" - integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= - -detect-node@^2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c" - integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw== - -dezalgo@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.3.tgz#7f742de066fc748bc8db820569dddce49bf0d456" - integrity sha1-f3Qt4Gb8dIvI24IFad3c5Jvw1FY= - dependencies: - asap "^2.0.0" - wrappy "1" - -di@^0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c" - integrity sha1-gGZJMmzqp8qjMG112YXqJ0i6kTw= - -diagnostics@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/diagnostics/-/diagnostics-1.1.1.tgz#cab6ac33df70c9d9a727490ae43ac995a769b22a" - integrity sha512-8wn1PmdunLJ9Tqbx+Fx/ZEuHfJf4NKSN2ZBj7SJC/OWRWha843+WsTjqMe1B5E3p28jqBlp+mJ2fPVxPyNgYKQ== - dependencies: - colorspace "1.1.x" - enabled "1.0.x" - kuler "1.0.x" - -diff@^3.1.0, diff@^3.2.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" - integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== - -diffie-hellman@^5.0.0: - version "5.0.3" - resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" - integrity sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg== - dependencies: - bn.js "^4.1.0" - miller-rabin "^4.0.0" - randombytes "^2.0.0" - -dir-glob@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.0.0.tgz#0b205d2b6aef98238ca286598a8204d29d0a0034" - integrity sha512-37qirFDz8cA5fimp9feo43fSuRo2gHwaIn6dXL8Ber1dGwUosDrGZeCCXq57WnIqE4aQ+u3eQZzsk1yOzhdwag== - dependencies: - arrify "^1.0.1" - path-type "^3.0.0" - -dns-equal@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/dns-equal/-/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d" - integrity sha1-s55/HabrCnW6nBcySzR1PEfgZU0= - -dns-packet@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-1.3.1.tgz#12aa426981075be500b910eedcd0b47dd7deda5a" - integrity sha512-0UxfQkMhYAUaZI+xrNZOz/as5KgDU0M/fQ9b6SpkyLbk3GEswDi6PADJVaYJradtRVsRIlF1zLyOodbcTCDzUg== - dependencies: - ip "^1.1.0" - safe-buffer "^5.0.1" - -dns-txt@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/dns-txt/-/dns-txt-2.0.2.tgz#b91d806f5d27188e4ab3e7d107d881a1cc4642b6" - integrity sha1-uR2Ab10nGI5Ks+fRB9iBocxGQrY= - dependencies: - buffer-indexof "^1.0.0" - -dom-serialize@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b" - integrity sha1-ViromZ9Evl6jB29UGdzVnrQ6yVs= - dependencies: - custom-event "~1.0.0" - ent "~2.2.0" - extend "^3.0.0" - void-elements "^2.0.0" - -domain-browser@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" - integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA== - -domino@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/domino/-/domino-2.1.0.tgz#653ba7d331441113b42e40ba05f24253ec86e02e" - integrity sha512-xINSODvrnuQcm3eXJN4IkBR+JxqLrJN8Ge4fd00y1b7HsY0A4huKN5BflSS/oo8quBWmocTfWdFvrw2H8TjGqQ== - -dot-prop@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-3.0.0.tgz#1b708af094a49c9a0e7dbcad790aba539dac1177" - integrity sha1-G3CK8JSknJoOfbyteQq6U52sEXc= - dependencies: - is-obj "^1.0.0" - -dot-prop@^4.1.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57" - integrity sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ== - dependencies: - is-obj "^1.0.0" - -duplexer2@~0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" - integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME= - dependencies: - readable-stream "^2.0.2" - -duplexer3@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" - integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI= - -duplexify@^3.4.2, duplexify@^3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.6.0.tgz#592903f5d80b38d037220541264d69a198fb3410" - integrity sha512-fO3Di4tBKJpYTFHAxTU00BcfWMY9w24r/x21a6rZRbsD/ToUgGxsMbiGRmB7uVAXeGKXD9MwiLZa5E97EVgIRQ== - dependencies: - end-of-stream "^1.0.0" - inherits "^2.0.1" - readable-stream "^2.0.0" - stream-shift "^1.0.0" - -ecc-jsbn@~0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" - integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= - dependencies: - jsbn "~0.1.0" - safer-buffer "^2.1.0" - -ee-first@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= - -electron-to-chromium@^1.3.62: - version "1.3.65" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.65.tgz#0655c238e45fea7e0e0e81fd0cac62b8186129c2" - integrity sha512-tyGr+wh2c/JYBVMeflKpZ3ricwtBPyVBMtRNxYGObl7TP5bAeupgz4VZnBzQSFleZViXYTws1yZtKKFQYZyiYw== - -elliptic@^6.0.0: - version "6.4.1" - resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.1.tgz#c2d0b7776911b86722c632c3c06c60f2f819939a" - integrity sha512-BsXLz5sqX8OHcsh7CqBMztyXARmGQ3LWPtGjJi6DiJHq5C/qvi9P3OqgswKSDftbu8+IoI/QDTAm2fFnQ9SZSQ== - dependencies: - bn.js "^4.4.0" - brorand "^1.0.1" - hash.js "^1.0.0" - hmac-drbg "^1.0.0" - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - minimalistic-crypto-utils "^1.0.0" - -emojis-list@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" - integrity sha1-TapNnbAPmBmIDHn6RXrlsJof04k= - -enabled@1.0.x: - version "1.0.2" - resolved "https://registry.yarnpkg.com/enabled/-/enabled-1.0.2.tgz#965f6513d2c2d1c5f4652b64a2e3396467fc2f93" - integrity sha1-ll9lE9LC0cX0ZStkouM5ZGf8L5M= - dependencies: - env-variable "0.0.x" - -encodeurl@~1.0.1, encodeurl@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= - -end-of-stream@^1.0.0, end-of-stream@^1.1.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43" - integrity sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q== - dependencies: - once "^1.4.0" - -engine.io-client@~3.2.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.2.1.tgz#6f54c0475de487158a1a7c77d10178708b6add36" - integrity sha512-y5AbkytWeM4jQr7m/koQLc5AxpRKC1hEVUb/s1FUAWEJq5AzJJ4NLvzuKPuxtDi5Mq755WuDvZ6Iv2rXj4PTzw== - dependencies: - component-emitter "1.2.1" - component-inherit "0.0.3" - debug "~3.1.0" - engine.io-parser "~2.1.1" - has-cors "1.1.0" - indexof "0.0.1" - parseqs "0.0.5" - parseuri "0.0.5" - ws "~3.3.1" - xmlhttprequest-ssl "~1.5.4" - yeast "0.1.2" - -engine.io-parser@~2.1.0, engine.io-parser@~2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.1.2.tgz#4c0f4cff79aaeecbbdcfdea66a823c6085409196" - integrity sha512-dInLFzr80RijZ1rGpx1+56/uFoH7/7InhH3kZt+Ms6hT8tNx3NGW/WNSA/f8As1WkOfkuyb3tnRyuXGxusclMw== - dependencies: - after "0.8.2" - arraybuffer.slice "~0.0.7" - base64-arraybuffer "0.1.5" - blob "0.0.4" - has-binary2 "~1.0.2" - -engine.io@~3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.2.0.tgz#54332506f42f2edc71690d2f2a42349359f3bf7d" - integrity sha512-mRbgmAtQ4GAlKwuPnnAvXXwdPhEx+jkc0OBCLrXuD/CRvwNK3AxRSnqK4FSqmAMRRHryVJP8TopOvmEaA64fKw== - dependencies: - accepts "~1.3.4" - base64id "1.0.0" - cookie "0.3.1" - debug "~3.1.0" - engine.io-parser "~2.1.0" - ws "~3.3.1" - -enhanced-resolve@4.1.0, enhanced-resolve@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.1.0.tgz#41c7e0bfdfe74ac1ffe1e57ad6a5c6c9f3742a7f" - integrity sha512-F/7vkyTtyc/llOIn8oWclcB25KdRaiPBpZYDgJHgh/UHtpgT2p2eldQgtQnLtUvfMKPKxbRaQM/hHkvLHt1Vng== - dependencies: - graceful-fs "^4.1.2" - memory-fs "^0.4.0" - tapable "^1.0.0" - -ent@~2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d" - integrity sha1-6WQhkyWiHQX0RGai9obtbOX13R0= - -env-variable@0.0.x: - version "0.0.4" - resolved "https://registry.yarnpkg.com/env-variable/-/env-variable-0.0.4.tgz#0d6280cf507d84242befe35a512b5ae4be77c54e" - integrity sha512-+jpGxSWG4vr6gVxUHOc4p+ilPnql7NzZxOZBxNldsKGjCF+97df3CbuX7XMaDa5oAVkKQj4rKp38rYdC4VcpDg== - -errno@^0.1.1, errno@^0.1.3, errno@~0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618" - integrity sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg== - dependencies: - prr "~1.0.1" - -error-ex@^1.2.0, error-ex@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" - integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== - dependencies: - is-arrayish "^0.2.1" - -es6-promise@^4.0.3: - version "4.2.5" - resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.5.tgz#da6d0d5692efb461e082c14817fe2427d8f5d054" - integrity sha512-n6wvpdE43VFtJq+lUDYDBFUwV8TZbuGXLV4D6wKafg13ldznKsyEvatubnmUe31zcvelSzOHF+XbaT+Bl9ObDg== - -es6-promise@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.0.2.tgz#010d5858423a5f118979665f46486a95c6ee2bb6" - integrity sha1-AQ1YWEI6XxGJeWZfRkhqlcbuK7Y= - -es6-promisify@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203" - integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM= - dependencies: - es6-promise "^4.0.3" - -escape-html@~1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= - -escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= - -escodegen@1.8.x: - version "1.8.1" - resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.8.1.tgz#5a5b53af4693110bebb0867aa3430dd3b70a1018" - integrity sha1-WltTr0aTEQvrsIZ6o0MN07cKEBg= - dependencies: - esprima "^2.7.1" - estraverse "^1.9.1" - esutils "^2.0.2" - optionator "^0.8.1" - optionalDependencies: - source-map "~0.2.0" - -escodegen@^1.8.1: - version "1.11.0" - resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.11.0.tgz#b27a9389481d5bfd5bec76f7bb1eb3f8f4556589" - integrity sha512-IeMV45ReixHS53K/OmfKAIztN/igDHzTJUhZM3k1jMhIZWjk45SMwAtBsEXiJp3vSPmTcu6CXn7mDvFHRN66fw== - dependencies: - esprima "^3.1.3" - estraverse "^4.2.0" - esutils "^2.0.2" - optionator "^0.8.1" - optionalDependencies: - source-map "~0.6.1" - -escodegen@~1.9.0: - version "1.9.1" - resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.9.1.tgz#dbae17ef96c8e4bedb1356f4504fa4cc2f7cb7e2" - integrity sha512-6hTjO1NAWkHnDk3OqQ4YrCuwwmGHL9S3nPlzBOUG/R44rda3wLNrfvQ5fkSGjyhHFKM7ALPKcKGrwvCLe0lC7Q== - dependencies: - esprima "^3.1.3" - estraverse "^4.2.0" - esutils "^2.0.2" - optionator "^0.8.1" - optionalDependencies: - source-map "~0.6.1" - -eslint-scope@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.0.tgz#50bf3071e9338bcdc43331794a0cb533f0136172" - integrity sha512-1G6UTDi7Jc1ELFwnR58HV4fK9OQK4S6N985f166xqXxpjU6plxFISJa2Ba9KCQuFa8RCnj/lSFJbHo7UFDBnUA== - dependencies: - esrecurse "^4.1.0" - estraverse "^4.1.1" - -esprima@2.7.x, esprima@^2.7.1: - version "2.7.3" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581" - integrity sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE= - -esprima@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633" - integrity sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM= - -esprima@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" - integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== - -esrecurse@^4.1.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.2.1.tgz#007a3b9fdbc2b3bb87e4879ea19c92fdbd3942cf" - integrity sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ== - dependencies: - estraverse "^4.1.0" - -estraverse@^1.9.1: - version "1.9.3" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-1.9.3.tgz#af67f2dc922582415950926091a4005d29c9bb44" - integrity sha1-r2fy3JIlgkFZUJJgkaQAXSnJu0Q= - -estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13" - integrity sha1-De4/7TH81GlhjOc0IJn8GvoL2xM= - -estree-walker@^0.5.1, estree-walker@^0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.5.2.tgz#d3850be7529c9580d815600b53126515e146dd39" - integrity sha512-XpCnW/AE10ws/kDAs37cngSkvgIR8aN3G0MS85m7dUpuK2EREo9VJ00uvw6Dg/hXEpfsE1I1TvJOJr+Z+TL+ig== - -esutils@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" - integrity sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs= - -etag@~1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= - -eventemitter3@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.0.tgz#090b4d6cdbd645ed10bf750d4b5407942d7ba163" - integrity sha512-ivIvhpq/Y0uSjcHDcOIccjmYjGLcP09MFGE7ysAwkAvkXfpZlC985pH2/ui64DKazbTW/4kN3yqozUxlXzI6cA== - -events@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" - integrity sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ= - -eventsource@0.1.6: - version "0.1.6" - resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-0.1.6.tgz#0acede849ed7dd1ccc32c811bb11b944d4f29232" - integrity sha1-Cs7ehJ7X3RzMMsgRuxG5RNTykjI= - dependencies: - original ">=0.0.5" - -eventsource@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-1.0.7.tgz#8fbc72c93fcd34088090bc0a4e64f4b5cee6d8d0" - integrity sha512-4Ln17+vVT0k8aWq+t/bF5arcS3EpT9gYtW66EPacdj/mAFevznsnyoHLPy2BA8gbIQeIHoPsvwmfBftfcG//BQ== - dependencies: - original "^1.0.0" - -evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" - integrity sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA== - dependencies: - md5.js "^1.3.4" - safe-buffer "^5.1.1" - -execa@^0.10.0: - version "0.10.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-0.10.0.tgz#ff456a8f53f90f8eccc71a96d11bdfc7f082cb50" - integrity sha512-7XOMnz8Ynx1gGo/3hyV9loYNPWM94jG3+3T3Y8tsfSstFmETmENCMU/A/zj8Lyaj1lkgEepKepvd6240tBRvlw== - dependencies: - cross-spawn "^6.0.0" - get-stream "^3.0.0" - is-stream "^1.1.0" - npm-run-path "^2.0.0" - p-finally "^1.0.0" - signal-exit "^3.0.0" - strip-eof "^1.0.0" - -execa@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" - integrity sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c= - dependencies: - cross-spawn "^5.0.1" - get-stream "^3.0.0" - is-stream "^1.1.0" - npm-run-path "^2.0.0" - p-finally "^1.0.0" - signal-exit "^3.0.0" - strip-eof "^1.0.0" - -exit@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" - integrity sha1-BjJjj42HfMghB9MKD/8aF8uhzQw= - -expand-braces@^0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/expand-braces/-/expand-braces-0.1.2.tgz#488b1d1d2451cb3d3a6b192cfc030f44c5855fea" - integrity sha1-SIsdHSRRyz06axks/AMPRMWFX+o= - dependencies: - array-slice "^0.2.3" - array-unique "^0.2.1" - braces "^0.1.2" - -expand-brackets@^0.1.4: - version "0.1.5" - resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b" - integrity sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s= - dependencies: - is-posix-bracket "^0.1.0" - -expand-brackets@^2.1.4: - version "2.1.4" - resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" - integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI= - dependencies: - debug "^2.3.3" - define-property "^0.2.5" - extend-shallow "^2.0.1" - posix-character-classes "^0.1.0" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.1" - -expand-range@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-0.1.1.tgz#4cb8eda0993ca56fa4f41fc42f3cbb4ccadff044" - integrity sha1-TLjtoJk8pW+k9B/ELzy7TMrf8EQ= - dependencies: - is-number "^0.1.1" - repeat-string "^0.2.2" - -expand-range@^1.8.1: - version "1.8.2" - resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337" - integrity sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc= - dependencies: - fill-range "^2.1.0" - -express@^4.16.2: - version "4.16.3" - resolved "http://registry.npmjs.org/express/-/express-4.16.3.tgz#6af8a502350db3246ecc4becf6b5a34d22f7ed53" - integrity sha1-avilAjUNsyRuzEvs9rWjTSL37VM= - dependencies: - accepts "~1.3.5" - array-flatten "1.1.1" - body-parser "1.18.2" - content-disposition "0.5.2" - content-type "~1.0.4" - cookie "0.3.1" - cookie-signature "1.0.6" - debug "2.6.9" - depd "~1.1.2" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - finalhandler "1.1.1" - fresh "0.5.2" - merge-descriptors "1.0.1" - methods "~1.1.2" - on-finished "~2.3.0" - parseurl "~1.3.2" - path-to-regexp "0.1.7" - proxy-addr "~2.0.3" - qs "6.5.1" - range-parser "~1.2.0" - safe-buffer "5.1.1" - send "0.16.2" - serve-static "1.13.2" - setprototypeof "1.1.0" - statuses "~1.4.0" - type-is "~1.6.16" - utils-merge "1.0.1" - vary "~1.1.2" - -extend-shallow@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" - integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8= - dependencies: - is-extendable "^0.1.0" - -extend-shallow@^3.0.0, extend-shallow@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" - integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg= - dependencies: - assign-symbols "^1.0.0" - is-extendable "^1.0.1" - -extend@^3.0.0, extend@~3.0.1, extend@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" - integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== - -external-editor@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.0.3.tgz#5866db29a97826dbe4bf3afd24070ead9ea43a27" - integrity sha512-bn71H9+qWoOQKyZDo25mOMVpSmXROAsTJVVVYzrrtol3d4y+AsKjf4Iwl2Q+IuT0kFSQ1qo166UuIwqYq7mGnA== - dependencies: - chardet "^0.7.0" - iconv-lite "^0.4.24" - tmp "^0.0.33" - -extglob@^0.3.1: - version "0.3.2" - resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1" - integrity sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE= - dependencies: - is-extglob "^1.0.0" - -extglob@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" - integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw== - dependencies: - array-unique "^0.3.2" - define-property "^1.0.0" - expand-brackets "^2.1.4" - extend-shallow "^2.0.1" - fragment-cache "^0.2.1" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.1" - -extsprintf@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" - integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= - -extsprintf@^1.2.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" - integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= - -falafel@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/falafel/-/falafel-2.1.0.tgz#96bb17761daba94f46d001738b3cedf3a67fe06c" - integrity sha1-lrsXdh2rqU9G0AFzizzt86Z/4Gw= - dependencies: - acorn "^5.0.0" - foreach "^2.0.5" - isarray "0.0.1" - object-keys "^1.0.6" - -fast-deep-equal@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz#c053477817c86b51daa853c81e059b733d023614" - integrity sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ= - -fast-deep-equal@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" - integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk= - -fast-json-stable-stringify@2.0.0, fast-json-stable-stringify@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" - integrity sha1-1RQsDK7msRifh9OnYREGT4bIu/I= - -fast-levenshtein@~2.0.4: - version "2.0.6" - resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" - integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= - -fast-safe-stringify@^2.0.4: - version "2.0.6" - resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.6.tgz#04b26106cc56681f51a044cfc0d76cf0008ac2c2" - integrity sha512-q8BZ89jjc+mz08rSxROs8VsrBBcn1SIw1kq9NjolL509tkABRk9io01RAjSaEv1Xb2uFLt8VtRiZbGp5H8iDtg== - -fastparse@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.1.tgz#d1e2643b38a94d7583b479060e6c4affc94071f8" - integrity sha1-0eJkOzipTXWDtHkGDmxK/8lAcfg= - -faye-websocket@^0.10.0: - version "0.10.0" - resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.10.0.tgz#4e492f8d04dfb6f89003507f6edbf2d501e7c6f4" - integrity sha1-TkkvjQTftviQA1B/btvy1QHnxvQ= - dependencies: - websocket-driver ">=0.5.1" - -faye-websocket@~0.11.0, faye-websocket@~0.11.1: - version "0.11.1" - resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.1.tgz#f0efe18c4f56e4f40afc7e06c719fd5ee6188f38" - integrity sha1-8O/hjE9W5PQK/H4Gxxn9XuYYjzg= - dependencies: - websocket-driver ">=0.5.1" - -fecha@^2.3.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/fecha/-/fecha-2.3.3.tgz#948e74157df1a32fd1b12c3a3c3cdcb6ec9d96cd" - integrity sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg== - -figgy-pudding@^3.1.0, figgy-pudding@^3.5.1: - version "3.5.1" - resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" - integrity sha512-vNKxJHTEKNThjfrdJwHc7brvM6eVevuO5nTj6ez8ZQ1qbXTvGthucRF7S4vf2cr71QVnT70V34v0S1DyQsti0w== - -figures@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" - integrity sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI= - dependencies: - escape-string-regexp "^1.0.5" - -file-loader@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-2.0.0.tgz#39749c82f020b9e85901dcff98e8004e6401cfde" - integrity sha512-YCsBfd1ZGCyonOKLxPiKPdu+8ld9HAaMEvJewzz+b2eTF7uL5Zm/HdBF6FjCrpCMRq25Mi0U1gl4pwn2TlH7hQ== - dependencies: - loader-utils "^1.0.2" - schema-utils "^1.0.0" - -filename-regex@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26" - integrity sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY= - -fileset@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/fileset/-/fileset-2.0.3.tgz#8e7548a96d3cc2327ee5e674168723a333bba2a0" - integrity sha1-jnVIqW08wjJ+5eZ0FocjozO7oqA= - dependencies: - glob "^7.0.3" - minimatch "^3.0.3" - -fill-range@^2.1.0: - version "2.2.4" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565" - integrity sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q== - dependencies: - is-number "^2.1.0" - isobject "^2.0.0" - randomatic "^3.0.0" - repeat-element "^1.1.2" - repeat-string "^1.5.2" - -fill-range@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" - integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc= - dependencies: - extend-shallow "^2.0.1" - is-number "^3.0.0" - repeat-string "^1.6.1" - to-regex-range "^2.1.0" - -finalhandler@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.0.tgz#ce0b6855b45853e791b2fcc680046d88253dd7f5" - integrity sha1-zgtoVbRYU+eRsvzGgARtiCU91/U= - dependencies: - debug "2.6.9" - encodeurl "~1.0.1" - escape-html "~1.0.3" - on-finished "~2.3.0" - parseurl "~1.3.2" - statuses "~1.3.1" - unpipe "~1.0.0" - -finalhandler@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.1.tgz#eebf4ed840079c83f4249038c9d703008301b105" - integrity sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg== - dependencies: - debug "2.6.9" - encodeurl "~1.0.2" - escape-html "~1.0.3" - on-finished "~2.3.0" - parseurl "~1.3.2" - statuses "~1.4.0" - unpipe "~1.0.0" - -find-cache-dir@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-1.0.0.tgz#9288e3e9e3cc3748717d39eade17cf71fc30ee6f" - integrity sha1-kojj6ePMN0hxfTnq3hfPcfww7m8= - dependencies: - commondir "^1.0.1" - make-dir "^1.0.0" - pkg-dir "^2.0.0" - -find-cache-dir@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-2.0.0.tgz#4c1faed59f45184530fb9d7fa123a4d04a98472d" - integrity sha512-LDUY6V1Xs5eFskUVYtIwatojt6+9xC9Chnlk/jYOOvn3FAFfSaWddxahDGyNHh0b2dMXa6YW2m0tk8TdVaXHlA== - dependencies: - commondir "^1.0.1" - make-dir "^1.0.0" - pkg-dir "^3.0.0" - -find-parent-dir@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/find-parent-dir/-/find-parent-dir-0.3.0.tgz#33c44b429ab2b2f0646299c5f9f718f376ff8d54" - integrity sha1-M8RLQpqysvBkYpnF+fcY83b/jVQ= - -find-up@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" - integrity sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8= - dependencies: - path-exists "^2.0.0" - pinkie-promise "^2.0.0" - -find-up@^2.0.0, find-up@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" - integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c= - dependencies: - locate-path "^2.0.0" - -find-up@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" - integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== - dependencies: - locate-path "^3.0.0" - -flush-write-stream@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.0.3.tgz#c5d586ef38af6097650b49bc41b55fabb19f35bd" - integrity sha512-calZMC10u0FMUqoiunI2AiGIIUtUIvifNwkHhNupZH4cbNnW1Itkoh/Nf5HFYmDrwWPjrUxpkZT0KhuCq0jmGw== - dependencies: - inherits "^2.0.1" - readable-stream "^2.0.4" - -follow-redirects@^1.0.0: - version "1.5.7" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.7.tgz#a39e4804dacb90202bca76a9e2ac10433ca6a69a" - integrity sha512-NONJVIFiX7Z8k2WxfqBjtwqMifx7X42ORLFrOZ2LTKGj71G3C0kfdyTqGqr8fx5zSX6Foo/D95dgGWbPUiwnew== - dependencies: - debug "^3.1.0" - -font-awesome@^4.7.0: - version "4.7.0" - resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133" - integrity sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM= - -for-in@^0.1.3: - version "0.1.8" - resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1" - integrity sha1-2Hc5COMSVhCZUrH9ubP6hn0ndeE= - -for-in@^1.0.1, for-in@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" - integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= - -for-own@^0.1.4: - version "0.1.5" - resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce" - integrity sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4= - dependencies: - for-in "^1.0.1" - -for-own@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/for-own/-/for-own-1.0.0.tgz#c63332f415cedc4b04dbfe70cf836494c53cb44b" - integrity sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs= - dependencies: - for-in "^1.0.1" - -foreach@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99" - integrity sha1-C+4AUBiusmDQo6865ljdATbsG5k= - -forever-agent@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" - integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= - -form-data@~2.3.1, form-data@~2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.2.tgz#4970498be604c20c005d4f5c23aecd21d6b49099" - integrity sha1-SXBJi+YEwgwAXU9cI67NIda0kJk= - dependencies: - asynckit "^0.4.0" - combined-stream "1.0.6" - mime-types "^2.1.12" - -forwarded@~0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" - integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ= - -fragment-cache@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" - integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk= - dependencies: - map-cache "^0.2.2" - -fresh@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" - integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= - -from2@^2.1.0, from2@^2.1.1: - version "2.3.0" - resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" - integrity sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8= - dependencies: - inherits "^2.0.1" - readable-stream "^2.0.0" - -fs-access@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/fs-access/-/fs-access-1.0.1.tgz#d6a87f262271cefebec30c553407fb995da8777a" - integrity sha1-1qh/JiJxzv6+wwxVNAf7mV2od3o= - dependencies: - null-check "^1.0.0" - -fs-extra@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.0.tgz#8cc3f47ce07ef7b3593a11b9fb245f7e34c041d6" - integrity sha512-EglNDLRpmaTWiD/qraZn6HREAEAHJcJOmxNEYwq6xeMKnVMAy3GUcFB+wXt2C6k4CNvB/mP1y/U3dzvKKj5OtQ== - dependencies: - graceful-fs "^4.1.2" - jsonfile "^4.0.0" - universalify "^0.1.0" - -fs-minipass@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.5.tgz#06c277218454ec288df77ada54a03b8702aacb9d" - integrity sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ== - dependencies: - minipass "^2.2.1" - -fs-write-stream-atomic@^1.0.8: - version "1.0.10" - resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9" - integrity sha1-tH31NJPvkR33VzHnCp3tAYnbQMk= - dependencies: - graceful-fs "^4.1.2" - iferr "^0.1.5" - imurmurhash "^0.1.4" - readable-stream "1 || 2" - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= - -fsevents@^1.0.0, fsevents@^1.2.2: - version "1.2.4" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.4.tgz#f41dcb1af2582af3692da36fc55cbd8e1041c426" - integrity sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg== - dependencies: - nan "^2.9.2" - node-pre-gyp "^0.10.0" - -fstream@^1.0.0, fstream@^1.0.2: - version "1.0.11" - resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.11.tgz#5c1fb1f117477114f0632a0eb4b71b3cb0fd3171" - integrity sha1-XB+x8RdHcRTwYyoOtLcbPLD9MXE= - dependencies: - graceful-fs "^4.1.2" - inherits "~2.0.0" - mkdirp ">=0.5 0" - rimraf "2" - -function-bind@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" - integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== - -gauge@~2.7.3: - version "2.7.4" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" - integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= - dependencies: - aproba "^1.0.3" - console-control-strings "^1.0.0" - has-unicode "^2.0.0" - object-assign "^4.1.0" - signal-exit "^3.0.0" - string-width "^1.0.1" - strip-ansi "^3.0.1" - wide-align "^1.1.0" - -gaze@^1.0.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/gaze/-/gaze-1.1.3.tgz#c441733e13b927ac8c0ff0b4c3b033f28812924a" - integrity sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g== - dependencies: - globule "^1.0.0" - -get-caller-file@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" - integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w== - -get-pkg-repo@^1.0.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/get-pkg-repo/-/get-pkg-repo-1.4.0.tgz#c73b489c06d80cc5536c2c853f9e05232056972d" - integrity sha1-xztInAbYDMVTbCyFP54FIyBWly0= - dependencies: - hosted-git-info "^2.1.4" - meow "^3.3.0" - normalize-package-data "^2.3.0" - parse-github-repo-url "^1.3.0" - through2 "^2.0.0" - -get-stdin@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe" - integrity sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4= - -get-stream@3.0.0, get-stream@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" - integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ= - -get-value@^2.0.3, get-value@^2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" - integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= - -getpass@^0.1.1: - version "0.1.7" - resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" - integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= - dependencies: - assert-plus "^1.0.0" - -gh-got@^7.0.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/gh-got/-/gh-got-7.1.0.tgz#f2b14eef375ce49b66d5f034a1c124f56dc5bcf0" - integrity sha512-KeWkkhresa7sbpzQLYzITMgez5rMigUsijhmSAHcLDORIMUbdlkdoZyaN1wQvIjmUZnyb/wkAPaXb4MQKX0mdQ== - dependencies: - got "^8.0.0" - is-plain-obj "^1.1.0" - -git-raw-commits@^1.3.6: - version "1.3.6" - resolved "https://registry.yarnpkg.com/git-raw-commits/-/git-raw-commits-1.3.6.tgz#27c35a32a67777c1ecd412a239a6c19d71b95aff" - integrity sha512-svsK26tQ8vEKnMshTDatSIQSMDdz8CxIIqKsvPqbtV23Etmw6VNaFAitu8zwZ0VrOne7FztwPyRLxK7/DIUTQg== - dependencies: - dargs "^4.0.1" - lodash.template "^4.0.2" - meow "^4.0.0" - split2 "^2.0.0" - through2 "^2.0.0" - -git-raw-commits@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/git-raw-commits/-/git-raw-commits-2.0.0.tgz#d92addf74440c14bcc5c83ecce3fb7f8a79118b5" - integrity sha512-w4jFEJFgKXMQJ0H0ikBk2S+4KP2VEjhCvLCNqbNRQC8BgGWgLKNCO7a9K9LI+TVT7Gfoloje502sEnctibffgg== - dependencies: - dargs "^4.0.1" - lodash.template "^4.0.2" - meow "^4.0.0" - split2 "^2.0.0" - through2 "^2.0.0" - -git-remote-origin-url@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/git-remote-origin-url/-/git-remote-origin-url-2.0.0.tgz#5282659dae2107145a11126112ad3216ec5fa65f" - integrity sha1-UoJlna4hBxRaERJhEq0yFuxfpl8= - dependencies: - gitconfiglocal "^1.0.0" - pify "^2.3.0" - -git-semver-tags@^1.3.6: - version "1.3.6" - resolved "https://registry.yarnpkg.com/git-semver-tags/-/git-semver-tags-1.3.6.tgz#357ea01f7280794fe0927f2806bee6414d2caba5" - integrity sha512-2jHlJnln4D/ECk9FxGEBh3k44wgYdWjWDtMmJPaecjoRmxKo3Y1Lh8GMYuOPu04CHw86NTAODchYjC5pnpMQig== - dependencies: - meow "^4.0.0" - semver "^5.5.0" - -gitconfiglocal@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/gitconfiglocal/-/gitconfiglocal-1.0.0.tgz#41d045f3851a5ea88f03f24ca1c6178114464b9b" - integrity sha1-QdBF84UaXqiPA/JMocYXgRRGS5s= - dependencies: - ini "^1.3.2" - -glob-base@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" - integrity sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q= - dependencies: - glob-parent "^2.0.0" - is-glob "^2.0.0" - -glob-parent@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28" - integrity sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg= - dependencies: - is-glob "^2.0.0" - -glob-parent@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" - integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4= - dependencies: - is-glob "^3.1.0" - path-dirname "^1.0.0" - -glob@7.0.x: - version "7.0.6" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.0.6.tgz#211bafaf49e525b8cd93260d14ab136152b3f57a" - integrity sha1-IRuvr0nlJbjNkyYNFKsTYVKz9Xo= - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.2" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@7.1.3, glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.0.6, glob@^7.1.1, glob@^7.1.2, glob@~7.1.1: - version "7.1.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1" - integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@^5.0.10, glob@^5.0.15: - version "5.0.15" - resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1" - integrity sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E= - dependencies: - inflight "^1.0.4" - inherits "2" - minimatch "2 || 3" - once "^1.3.0" - path-is-absolute "^1.0.0" - -global-dirs@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445" - integrity sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU= - dependencies: - ini "^1.3.4" - -globals@^11.1.0: - version "11.7.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-11.7.0.tgz#a583faa43055b1aca771914bf68258e2fc125673" - integrity sha512-K8BNSPySfeShBQXsahYB/AbbWruVOTyVpgoIDnl8odPpeSfP2J5QO2oLFFdl2j7GfDCtZj2bMKar2T49itTPCg== - -globals@^9.18.0: - version "9.18.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a" - integrity sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ== - -globby@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-5.0.0.tgz#ebd84667ca0dbb330b99bcfc68eac2bc54370e0d" - integrity sha1-69hGZ8oNuzMLmbz8aOrCvFQ3Dg0= - dependencies: - array-union "^1.0.1" - arrify "^1.0.0" - glob "^7.0.3" - object-assign "^4.0.1" - pify "^2.0.0" - pinkie-promise "^2.0.0" - -globby@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c" - integrity sha1-9abXDoOV4hyFj7BInWTfAkJNUGw= - dependencies: - array-union "^1.0.1" - glob "^7.0.3" - object-assign "^4.0.1" - pify "^2.0.0" - pinkie-promise "^2.0.0" - -globby@^7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/globby/-/globby-7.1.1.tgz#fb2ccff9401f8600945dfada97440cca972b8680" - integrity sha1-+yzP+UAfhgCUXfral0QMypcrhoA= - dependencies: - array-union "^1.0.1" - dir-glob "^2.0.0" - glob "^7.1.2" - ignore "^3.3.5" - pify "^3.0.0" - slash "^1.0.0" - -globule@^1.0.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/globule/-/globule-1.2.1.tgz#5dffb1b191f22d20797a9369b49eab4e9839696d" - integrity sha512-g7QtgWF4uYSL5/dn71WxubOrS7JVGCnFPEnoeChJmBnyR9Mw8nGoEwOgJL/RC2Te0WhbsEUCejfH8SZNJ+adYQ== - dependencies: - glob "~7.1.1" - lodash "~4.17.10" - minimatch "~3.0.2" - -got@^6.7.1: - version "6.7.1" - resolved "http://registry.npmjs.org/got/-/got-6.7.1.tgz#240cd05785a9a18e561dc1b44b41c763ef1e8db0" - integrity sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA= - dependencies: - create-error-class "^3.0.0" - duplexer3 "^0.1.4" - get-stream "^3.0.0" - is-redirect "^1.0.0" - is-retry-allowed "^1.0.0" - is-stream "^1.0.0" - lowercase-keys "^1.0.0" - safe-buffer "^5.0.1" - timed-out "^4.0.0" - unzip-response "^2.0.1" - url-parse-lax "^1.0.0" - -got@^8.0.0: - version "8.3.2" - resolved "https://registry.yarnpkg.com/got/-/got-8.3.2.tgz#1d23f64390e97f776cac52e5b936e5f514d2e937" - integrity sha512-qjUJ5U/hawxosMryILofZCkm3C84PLJS/0grRIpjAwu+Lkxxj5cxeCU25BG0/3mDSpXKTyZr8oh8wIgLaH0QCw== - dependencies: - "@sindresorhus/is" "^0.7.0" - cacheable-request "^2.1.1" - decompress-response "^3.3.0" - duplexer3 "^0.1.4" - get-stream "^3.0.0" - into-stream "^3.1.0" - is-retry-allowed "^1.1.0" - isurl "^1.0.0-alpha5" - lowercase-keys "^1.0.0" - mimic-response "^1.0.0" - p-cancelable "^0.4.0" - p-timeout "^2.0.1" - pify "^3.0.0" - safe-buffer "^5.1.1" - timed-out "^4.0.1" - url-parse-lax "^3.0.0" - url-to-options "^1.0.1" - -graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6: - version "4.1.11" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" - integrity sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg= - -handle-thing@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-1.2.5.tgz#fd7aad726bf1a5fd16dfc29b2f7a6601d27139c4" - integrity sha1-/Xqtcmvxpf0W38KbL3pmAdJxOcQ= - -handlebars@^4.0.1, handlebars@^4.0.11, handlebars@^4.0.2: - version "4.0.12" - resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.12.tgz#2c15c8a96d46da5e266700518ba8cb8d919d5bc5" - integrity sha512-RhmTekP+FZL+XNhwS1Wf+bTTZpdLougwt5pcgA1tuz6Jcx0fpH/7z0qd71RKnZHBCxIRBHfBOnio4gViPemNzA== - dependencies: - async "^2.5.0" - optimist "^0.6.1" - source-map "^0.6.1" - optionalDependencies: - uglify-js "^3.1.4" - -har-schema@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" - integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= - -har-validator@~5.0.3: - version "5.0.3" - resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.0.3.tgz#ba402c266194f15956ef15e0fcf242993f6a7dfd" - integrity sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0= - dependencies: - ajv "^5.1.0" - har-schema "^2.0.0" - -har-validator@~5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.0.tgz#44657f5688a22cfd4b72486e81b3a3fb11742c29" - integrity sha512-+qnmNjI4OfH2ipQ9VQOw23bBd/ibtfbVdK2fYbY4acTDqKTW/YDp9McimZdDbG8iV9fZizUqQMD5xvriB146TA== - dependencies: - ajv "^5.3.0" - har-schema "^2.0.0" - -has-ansi@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" - integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE= - dependencies: - ansi-regex "^2.0.0" - -has-binary2@~1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d" - integrity sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw== - dependencies: - isarray "2.0.1" - -has-cors@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39" - integrity sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk= - -has-flag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa" - integrity sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo= - -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= - -has-symbol-support-x@^1.4.1: - version "1.4.2" - resolved "https://registry.yarnpkg.com/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz#1409f98bc00247da45da67cee0a36f282ff26455" - integrity sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw== - -has-to-string-tag-x@^1.2.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz#a045ab383d7b4b2012a00148ab0aa5f290044d4d" - integrity sha512-vdbKfmw+3LoOYVr+mtxHaX5a96+0f3DljYd8JOqvOLsf5mw2Otda2qCDT9qRqLAhrjyQ0h7ual5nOiASpsGNFw== - dependencies: - has-symbol-support-x "^1.4.1" - -has-unicode@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" - integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= - -has-value@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" - integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8= - dependencies: - get-value "^2.0.3" - has-values "^0.1.4" - isobject "^2.0.0" - -has-value@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" - integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc= - dependencies: - get-value "^2.0.6" - has-values "^1.0.0" - isobject "^3.0.0" - -has-values@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" - integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E= - -has-values@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" - integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8= - dependencies: - is-number "^3.0.0" - kind-of "^4.0.0" - -has@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" - integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== - dependencies: - function-bind "^1.1.1" - -hash-base@^3.0.0: - version "3.0.4" - resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.0.4.tgz#5fc8686847ecd73499403319a6b0a3f3f6ae4918" - integrity sha1-X8hoaEfs1zSZQDMZprCj8/auSRg= - dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" - -hash.js@^1.0.0, hash.js@^1.0.3: - version "1.1.5" - resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.5.tgz#e38ab4b85dfb1e0c40fe9265c0e9b54854c23812" - integrity sha512-eWI5HG9Np+eHV1KQhisXWwM+4EPPYe5dFX1UZZH7k/E3JzDEazVH+VGlZi6R94ZqImq+A3D1mCEtrFIfg/E7sA== - dependencies: - inherits "^2.0.3" - minimalistic-assert "^1.0.1" - -hmac-drbg@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" - integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE= - dependencies: - hash.js "^1.0.3" - minimalistic-assert "^1.0.0" - minimalistic-crypto-utils "^1.0.1" - -hosted-git-info@^2.1.4, hosted-git-info@^2.6.0: - version "2.7.1" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.7.1.tgz#97f236977bd6e125408930ff6de3eec6281ec047" - integrity sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w== - -hpack.js@^2.1.6: - version "2.1.6" - resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" - integrity sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI= - dependencies: - inherits "^2.0.1" - obuf "^1.0.0" - readable-stream "^2.0.1" - wbuf "^1.1.0" - -html-entities@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.2.1.tgz#0df29351f0721163515dfb9e5543e5f6eed5162f" - integrity sha1-DfKTUfByEWNRXfueVUPl9u7VFi8= - -http-cache-semantics@3.8.1: - version "3.8.1" - resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz#39b0e16add9b605bf0a9ef3d9daaf4843b4cacd2" - integrity sha512-5ai2iksyV8ZXmnZhHH4rWPoxxistEexSi5936zIQ1bnNTW5VnA85B6P/VpXiRM017IgRvb2kKo1a//y+0wSp3w== - -http-deceiver@^1.2.7: - version "1.2.7" - resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87" - integrity sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc= - -http-errors@1.6.2: - version "1.6.2" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736" - integrity sha1-CgAsyFcHGSp+eUbO7cERVfYOxzY= - dependencies: - depd "1.1.1" - inherits "2.0.3" - setprototypeof "1.0.3" - statuses ">= 1.3.1 < 2" - -http-errors@1.6.3, http-errors@~1.6.2, http-errors@~1.6.3: - version "1.6.3" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" - integrity sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0= - dependencies: - depd "~1.1.2" - inherits "2.0.3" - setprototypeof "1.1.0" - statuses ">= 1.4.0 < 2" - -http-parser-js@>=0.4.0: - version "0.4.13" - resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.4.13.tgz#3bd6d6fde6e3172c9334c3b33b6c193d80fe1137" - integrity sha1-O9bW/ebjFyyTNMOzO2wZPYD+ETc= - -http-proxy-middleware@~0.18.0: - version "0.18.0" - resolved "http://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-0.18.0.tgz#0987e6bb5a5606e5a69168d8f967a87f15dd8aab" - integrity sha512-Fs25KVMPAIIcgjMZkVHJoKg9VcXcC1C8yb9JUgeDvVXY0S/zgVIhMb+qVswDIgtJe2DfckMSY2d6TuTEutlk6Q== - dependencies: - http-proxy "^1.16.2" - is-glob "^4.0.0" - lodash "^4.17.5" - micromatch "^3.1.9" - -http-proxy@^1.13.0, http-proxy@^1.16.2: - version "1.17.0" - resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.17.0.tgz#7ad38494658f84605e2f6db4436df410f4e5be9a" - integrity sha512-Taqn+3nNvYRfJ3bGvKfBSRwy1v6eePlm3oc/aWVxZp57DQr5Eq3xhKJi7Z4hZpS8PC3H4qI+Yly5EmFacGuA/g== - dependencies: - eventemitter3 "^3.0.0" - follow-redirects "^1.0.0" - requires-port "^1.0.0" - -http-signature@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" - integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= - dependencies: - assert-plus "^1.0.0" - jsprim "^1.2.2" - sshpk "^1.7.0" - -https-browserify@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" - integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= - -https-proxy-agent@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz#51552970fa04d723e04c56d04178c3f92592bbc0" - integrity sha512-HPCTS1LW51bcyMYbxUIOO4HEOlQ1/1qRaFWcyxvwaqUS9TY88aoEuHUY33kuAh1YhVVaDQhLZsnPd+XNARWZlQ== - dependencies: - agent-base "^4.1.0" - debug "^3.1.0" - -husky@^0.14.3: - version "0.14.3" - resolved "https://registry.yarnpkg.com/husky/-/husky-0.14.3.tgz#c69ed74e2d2779769a17ba8399b54ce0b63c12c3" - integrity sha512-e21wivqHpstpoiWA/Yi8eFti8E+sQDSS53cpJsPptPs295QTOQR0ZwnHo2TXy1XOpZFD9rPOd3NpmqTK6uMLJA== - dependencies: - is-ci "^1.0.10" - normalize-path "^1.0.0" - strip-indent "^2.0.0" - -iconv-lite@0.4.19: - version "0.4.19" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" - integrity sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ== - -iconv-lite@0.4.23: - version "0.4.23" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63" - integrity sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - -iconv-lite@^0.4.24, iconv-lite@^0.4.4: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - -ieee754@^1.1.4: - version "1.1.12" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.12.tgz#50bf24e5b9c8bb98af4964c941cdb0918da7b60b" - integrity sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA== - -iferr@^0.1.5: - version "0.1.5" - resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" - integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= - -ignore-walk@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8" - integrity sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ== - dependencies: - minimatch "^3.0.4" - -ignore@^3.3.5: - version "3.3.10" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043" - integrity sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug== - -image-size@~0.5.0: - version "0.5.5" - resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c" - integrity sha1-Cd/Uq50g4p6xw+gLiZA3jfnjy5w= - -immediate@~3.0.5: - version "3.0.6" - resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" - integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= - -immutable@^3.8.2: - version "3.8.2" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" - integrity sha1-wkOZUUVbs5kT2vKBN28VMOEErfM= - -import-cwd@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" - integrity sha1-qmzzbnInYShcs3HsZRn1PiQ1sKk= - dependencies: - import-from "^2.1.0" - -import-from@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/import-from/-/import-from-2.1.0.tgz#335db7f2a7affd53aaa471d4b8021dee36b7f3b1" - integrity sha1-M1238qev/VOqpHHUuAId7ja387E= - dependencies: - resolve-from "^3.0.0" - -import-lazy@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" - integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM= - -import-local@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/import-local/-/import-local-2.0.0.tgz#55070be38a5993cf18ef6db7e961f5bee5c5a09d" - integrity sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ== - dependencies: - pkg-dir "^3.0.0" - resolve-cwd "^2.0.0" - -imurmurhash@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= - -in-publish@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/in-publish/-/in-publish-2.0.0.tgz#e20ff5e3a2afc2690320b6dc552682a9c7fadf51" - integrity sha1-4g/146KvwmkDILbcVSaCqcf631E= - -indent-string@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80" - integrity sha1-ji1INIdCEhtKghi3oTfppSBJ3IA= - dependencies: - repeating "^2.0.0" - -indent-string@^3.0.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-3.2.0.tgz#4a5fd6d27cc332f37e5419a504dbb837105c9289" - integrity sha1-Sl/W0nzDMvN+VBmlBNu4NxBckok= - -indexof@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d" - integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10= - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@2.0.3, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" - integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= - -inherits@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" - integrity sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE= - -ini@^1.3.2, ini@^1.3.4, ini@~1.3.0: - version "1.3.5" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== - -injection-js@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/injection-js/-/injection-js-2.2.1.tgz#a8d6a085b2f0b8d8650f6f4487f6abb8cc0d67ce" - integrity sha512-zHI+E+dM0PXix5FFTO1Y4/UOyAzE7zG1l/QwAn4jchTThOoBq+UYRFK4AVG7lQgFL+go62SbrzSsjXy9DFEZUg== - -inquirer@6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.2.0.tgz#51adcd776f661369dc1e894859c2560a224abdd8" - integrity sha512-QIEQG4YyQ2UYZGDC4srMZ7BjHOmNk1lR2JQj5UknBapklm6WHA+VVH7N+sUdX3A7NeCfGF8o4X1S3Ao7nAcIeg== - dependencies: - ansi-escapes "^3.0.0" - chalk "^2.0.0" - cli-cursor "^2.1.0" - cli-width "^2.0.0" - external-editor "^3.0.0" - figures "^2.0.0" - lodash "^4.17.10" - mute-stream "0.0.7" - run-async "^2.2.0" - rxjs "^6.1.0" - string-width "^2.1.0" - strip-ansi "^4.0.0" - through "^2.3.6" - -internal-ip@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-3.0.1.tgz#df5c99876e1d2eb2ea2d74f520e3f669a00ece27" - integrity sha512-NXXgESC2nNVtU+pqmC9e6R8B1GpKxzsAQhffvh5AL79qKnodd+L7tnEQmTiUAVngqLalPbSqRA7XGIEL5nCd0Q== - dependencies: - default-gateway "^2.6.0" - ipaddr.js "^1.5.2" - -interpret@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.1.0.tgz#7ed1b1410c6a0e0f78cf95d3b8440c63f78b8614" - integrity sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ= - -into-stream@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/into-stream/-/into-stream-3.1.0.tgz#96fb0a936c12babd6ff1752a17d05616abd094c6" - integrity sha1-lvsKk2wSur1v8XUqF9BWFqvQlMY= - dependencies: - from2 "^2.1.1" - p-is-promise "^1.1.0" - -invariant@^2.2.2: - version "2.2.4" - resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" - integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== - dependencies: - loose-envify "^1.0.0" - -invert-kv@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" - integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= - -invert-kv@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" - integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA== - -ip-regex@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" - integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= - -ip@^1.1.0, ip@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" - integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= - -ipaddr.js@1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.8.0.tgz#eaa33d6ddd7ace8f7f6fe0c9ca0440e706738b1e" - integrity sha1-6qM9bd16zo9/b+DJygRA5wZzix4= - -ipaddr.js@^1.5.2: - version "1.8.1" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.8.1.tgz#fa4b79fa47fd3def5e3b159825161c0a519c9427" - integrity sha1-+kt5+kf9Pe9eOxWYJRYcClGclCc= - -is-accessor-descriptor@^0.1.6: - version "0.1.6" - resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" - integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY= - dependencies: - kind-of "^3.0.2" - -is-accessor-descriptor@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" - integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ== - dependencies: - kind-of "^6.0.0" - -is-arrayish@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" - integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= - -is-arrayish@^0.3.1: - version "0.3.2" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" - integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== - -is-binary-path@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" - integrity sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg= - dependencies: - binary-extensions "^1.0.0" - -is-buffer@^1.1.5: - version "1.1.6" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" - integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== - -is-builtin-module@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-1.0.0.tgz#540572d34f7ac3119f8f76c30cbc1b1e037affbe" - integrity sha1-VAVy0096wxGfj3bDDLwbHgN6/74= - dependencies: - builtin-modules "^1.0.0" - -is-ci@^1.0.10: - version "1.2.1" - resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c" - integrity sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg== - dependencies: - ci-info "^1.5.0" - -is-data-descriptor@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" - integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y= - dependencies: - kind-of "^3.0.2" - -is-data-descriptor@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" - integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ== - dependencies: - kind-of "^6.0.0" - -is-descriptor@^0.1.0: - version "0.1.6" - resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" - integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg== - dependencies: - is-accessor-descriptor "^0.1.6" - is-data-descriptor "^0.1.4" - kind-of "^5.0.0" - -is-descriptor@^1.0.0, is-descriptor@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" - integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg== - dependencies: - is-accessor-descriptor "^1.0.0" - is-data-descriptor "^1.0.0" - kind-of "^6.0.2" - -is-directory@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" - integrity sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE= - -is-dotfile@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1" - integrity sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE= - -is-equal-shallow@^0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534" - integrity sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ= - dependencies: - is-primitive "^2.0.0" - -is-extendable@^0.1.0, is-extendable@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" - integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= - -is-extendable@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" - integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== - dependencies: - is-plain-object "^2.0.4" - -is-extglob@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0" - integrity sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA= - -is-extglob@^2.1.0, is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= - -is-finite@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa" - integrity sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko= - dependencies: - number-is-nan "^1.0.0" - -is-fullwidth-code-point@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" - integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= - dependencies: - number-is-nan "^1.0.0" - -is-fullwidth-code-point@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" - integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= - -is-glob@^2.0.0, is-glob@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" - integrity sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM= - dependencies: - is-extglob "^1.0.0" - -is-glob@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" - integrity sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo= - dependencies: - is-extglob "^2.1.0" - -is-glob@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.0.tgz#9521c76845cc2610a85203ddf080a958c2ffabc0" - integrity sha1-lSHHaEXMJhCoUgPd8ICpWML/q8A= - dependencies: - is-extglob "^2.1.1" - -is-installed-globally@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80" - integrity sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA= - dependencies: - global-dirs "^0.1.0" - is-path-inside "^1.0.0" - -is-module@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" - integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE= - -is-npm@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4" - integrity sha1-8vtjpl5JBbQGyGBydloaTceTufQ= - -is-number@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-0.1.1.tgz#69a7af116963d47206ec9bd9b48a14216f1e3806" - integrity sha1-aaevEWlj1HIG7JvZtIoUIW8eOAY= - -is-number@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" - integrity sha1-Afy7s5NGOlSPL0ZszhbezknbkI8= - dependencies: - kind-of "^3.0.2" - -is-number@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" - integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU= - dependencies: - kind-of "^3.0.2" - -is-number@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff" - integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ== - -is-obj@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" - integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8= - -is-object@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.1.tgz#8952688c5ec2ffd6b03ecc85e769e02903083470" - integrity sha1-iVJojF7C/9awPsyF52ngKQMINHA= - -is-path-cwd@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d" - integrity sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0= - -is-path-in-cwd@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz#5ac48b345ef675339bd6c7a48a912110b241cf52" - integrity sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ== - dependencies: - is-path-inside "^1.0.0" - -is-path-inside@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036" - integrity sha1-jvW33lBDej/cprToZe96pVy0gDY= - dependencies: - path-is-inside "^1.0.1" - -is-plain-obj@^1.0.0, is-plain-obj@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" - integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4= - -is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" - integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== - dependencies: - isobject "^3.0.1" - -is-posix-bracket@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4" - integrity sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q= - -is-primitive@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575" - integrity sha1-IHurkWOEmcB7Kt8kCkGochADRXU= - -is-promise@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" - integrity sha1-eaKp7OfwlugPNtKy87wWwf9L8/o= - -is-redirect@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24" - integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ= - -is-retry-allowed@^1.0.0, is-retry-allowed@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.1.0.tgz#11a060568b67339444033d0125a61a20d564fb34" - integrity sha1-EaBgVotnM5REAz0BJaYaINVk+zQ= - -is-stream@^1.0.0, is-stream@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" - integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= - -is-subset@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-subset/-/is-subset-0.1.1.tgz#8a59117d932de1de00f245fcdd39ce43f1e939a6" - integrity sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY= - -is-text-path@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-text-path/-/is-text-path-1.0.1.tgz#4e1aa0fb51bfbcb3e92688001397202c1775b66e" - integrity sha1-Thqg+1G/vLPpJogAE5cgLBd1tm4= - dependencies: - text-extensions "^1.0.0" - -is-typedarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" - integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= - -is-utf8@^0.2.0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" - integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI= - -is-windows@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" - integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== - -is-wsl@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" - integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= - -isarray@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" - integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= - -isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= - -isarray@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e" - integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4= - -isbinaryfile@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-3.0.3.tgz#5d6def3edebf6e8ca8cae9c30183a804b5f8be80" - integrity sha512-8cJBL5tTd2OS0dM4jz07wQd5g0dCCqIhUxPIGtZfa5L6hWlvV5MHTITy/DBAsF+Oe2LS1X3krBUhNwaGUWpWxw== - dependencies: - buffer-alloc "^1.2.0" - -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= - -isobject@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" - integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk= - dependencies: - isarray "1.0.0" - -isobject@^3.0.0, isobject@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" - integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= - -isstream@~0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" - integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= - -istanbul-api@^2.0.5: - version "2.0.6" - resolved "https://registry.yarnpkg.com/istanbul-api/-/istanbul-api-2.0.6.tgz#cd7b33ee678f6c01531d05f5e567ebbcd25f8ecc" - integrity sha512-8W5oeAGWXhtTJjAyVfvavOLVyZCTNCKsyF6GON/INKlBdO7uJ/bv3qnPj5M6ERKzmMCJS1kntnjjGuJ86fn3rQ== - dependencies: - async "^2.6.1" - compare-versions "^3.2.1" - fileset "^2.0.3" - istanbul-lib-coverage "^2.0.1" - istanbul-lib-hook "^2.0.1" - istanbul-lib-instrument "^3.0.0" - istanbul-lib-report "^2.0.2" - istanbul-lib-source-maps "^2.0.1" - istanbul-reports "^2.0.1" - js-yaml "^3.12.0" - make-dir "^1.3.0" - once "^1.4.0" - -istanbul-instrumenter-loader@3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/istanbul-instrumenter-loader/-/istanbul-instrumenter-loader-3.0.1.tgz#9957bd59252b373fae5c52b7b5188e6fde2a0949" - integrity sha512-a5SPObZgS0jB/ixaKSMdn6n/gXSrK2S6q/UfRJBT3e6gQmVjwZROTODQsYW5ZNwOu78hG62Y3fWlebaVOL0C+w== - dependencies: - convert-source-map "^1.5.0" - istanbul-lib-instrument "^1.7.3" - loader-utils "^1.1.0" - schema-utils "^0.3.0" - -istanbul-lib-coverage@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-1.2.1.tgz#ccf7edcd0a0bb9b8f729feeb0930470f9af664f0" - integrity sha512-PzITeunAgyGbtY1ibVIUiV679EFChHjoMNRibEIobvmrCRaIgwLxNucOSimtNWUhEib/oO7QY2imD75JVgCJWQ== - -istanbul-lib-coverage@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#2aee0e073ad8c5f6a0b00e0dfbf52b4667472eda" - integrity sha512-nPvSZsVlbG9aLhZYaC3Oi1gT/tpyo3Yt5fNyf6NmcKIayz4VV/txxJFFKAK/gU4dcNn8ehsanBbVHVl0+amOLA== - -istanbul-lib-hook@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-2.0.1.tgz#918a57b75a0f951d552a08487ca1fa5336433d72" - integrity sha512-ufiZoiJ8CxY577JJWEeFuxXZoMqiKpq/RqZtOAYuQLvlkbJWscq9n3gc4xrCGH9n4pW0qnTxOz1oyMmVtk8E1w== - dependencies: - append-transform "^1.0.0" - -istanbul-lib-instrument@^1.7.3: - version "1.10.2" - resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-1.10.2.tgz#1f55ed10ac3c47f2bdddd5307935126754d0a9ca" - integrity sha512-aWHxfxDqvh/ZlxR8BBaEPVSWDPUkGD63VjGQn3jcw8jCp7sHEMKcrj4xfJn/ABzdMEHiQNyvDQhqm5o8+SQg7A== - dependencies: - babel-generator "^6.18.0" - babel-template "^6.16.0" - babel-traverse "^6.18.0" - babel-types "^6.18.0" - babylon "^6.18.0" - istanbul-lib-coverage "^1.2.1" - semver "^5.3.0" - -istanbul-lib-instrument@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-3.0.0.tgz#b5f066b2a161f75788be17a9d556f40a0cf2afc9" - integrity sha512-eQY9vN9elYjdgN9Iv6NS/00bptm02EBBk70lRMaVjeA6QYocQgenVrSgC28TJurdnZa80AGO3ASdFN+w/njGiQ== - dependencies: - "@babel/generator" "^7.0.0" - "@babel/parser" "^7.0.0" - "@babel/template" "^7.0.0" - "@babel/traverse" "^7.0.0" - "@babel/types" "^7.0.0" - istanbul-lib-coverage "^2.0.1" - semver "^5.5.0" - -istanbul-lib-report@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-2.0.2.tgz#430a2598519113e1da7af274ba861bd42dd97535" - integrity sha512-rJ8uR3peeIrwAxoDEbK4dJ7cqqtxBisZKCuwkMtMv0xYzaAnsAi3AHrHPAAtNXzG/bcCgZZ3OJVqm1DTi9ap2Q== - dependencies: - istanbul-lib-coverage "^2.0.1" - make-dir "^1.3.0" - supports-color "^5.4.0" - -istanbul-lib-source-maps@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-2.0.1.tgz#ce8b45131d8293fdeaa732f4faf1852d13d0a97e" - integrity sha512-30l40ySg+gvBLcxTrLzR4Z2XTRj3HgRCA/p2rnbs/3OiTaoj054gAbuP5DcLOtwqmy4XW8qXBHzrmP2/bQ9i3A== - dependencies: - debug "^3.1.0" - istanbul-lib-coverage "^2.0.1" - make-dir "^1.3.0" - rimraf "^2.6.2" - source-map "^0.6.1" - -istanbul-reports@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-2.0.1.tgz#fb8d6ea850701a3984350b977a969e9a556116a7" - integrity sha512-CT0QgMBJqs6NJLF678ZHcquUAZIoBIUNzdJrRJfpkI9OnzG6MkUfHxbJC3ln981dMswC7/B1mfX3LNkhgJxsuw== - dependencies: - handlebars "^4.0.11" - -istanbul@0.4.5, istanbul@^0.4.5: - version "0.4.5" - resolved "https://registry.yarnpkg.com/istanbul/-/istanbul-0.4.5.tgz#65c7d73d4c4da84d4f3ac310b918fb0b8033733b" - integrity sha1-ZcfXPUxNqE1POsMQuRj7C4Azczs= - dependencies: - abbrev "1.0.x" - async "1.x" - escodegen "1.8.x" - esprima "2.7.x" - glob "^5.0.15" - handlebars "^4.0.1" - js-yaml "3.x" - mkdirp "0.5.x" - nopt "3.x" - once "1.x" - resolve "1.1.x" - supports-color "^3.1.0" - which "^1.1.1" - wordwrap "^1.0.0" - -isurl@^1.0.0-alpha5: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67" - integrity sha512-1P/yWsxPlDtn7QeRD+ULKQPaIaN6yF368GZ2vDfv0AL0NwpStafjWCDDdn0k8wgFMWpVAqG7oJhxHnlud42i9w== - dependencies: - has-to-string-tag-x "^1.2.0" - is-object "^1.0.1" - -jasmine-core@~2.8.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.8.0.tgz#bcc979ae1f9fd05701e45e52e65d3a5d63f1a24e" - integrity sha1-vMl5rh+f0FcB5F5S5l06XWPxok4= - -jasmine-core@~2.99.0: - version "2.99.1" - resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.99.1.tgz#e6400df1e6b56e130b61c4bcd093daa7f6e8ca15" - integrity sha1-5kAN8ea1bhMLYcS80JPap/boyhU= - -jasmine-diff@^0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/jasmine-diff/-/jasmine-diff-0.1.3.tgz#93ccc2dcc41028c5ddd4606558074839f2deeaa8" - integrity sha1-k8zC3MQQKMXd1GBlWAdIOfLe6qg= - dependencies: - diff "^3.2.0" - -jasmine-spec-reporter@^3.2.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/jasmine-spec-reporter/-/jasmine-spec-reporter-3.3.0.tgz#c63c3d43bacfd16e6da86c46d265957e0075f14c" - integrity sha1-xjw9Q7rP0W5tqGxG0mWVfgB18Uw= - dependencies: - colors "1.1.2" - -jasmine-spec-reporter@~4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/jasmine-spec-reporter/-/jasmine-spec-reporter-4.2.1.tgz#1d632aec0341670ad324f92ba84b4b32b35e9e22" - integrity sha512-FZBoZu7VE5nR7Nilzy+Np8KuVIOxF4oXDPDknehCYBDE080EnlPu0afdZNmpGDBRCUBv3mj5qgqCRmk6W/K8vg== - dependencies: - colors "1.1.2" - -jasmine@2.8.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/jasmine/-/jasmine-2.8.0.tgz#6b089c0a11576b1f16df11b80146d91d4e8b8a3e" - integrity sha1-awicChFXax8W3xG4AUbZHU6Lij4= - dependencies: - exit "^0.1.2" - glob "^7.0.6" - jasmine-core "~2.8.0" - -jasmine@^2.6.0: - version "2.99.0" - resolved "https://registry.yarnpkg.com/jasmine/-/jasmine-2.99.0.tgz#8ca72d102e639b867c6489856e0e18a9c7aa42b7" - integrity sha1-jKctEC5jm4Z8ZImFbg4YqceqQrc= - dependencies: - exit "^0.1.2" - glob "^7.0.6" - jasmine-core "~2.99.0" - -jasminewd2@^2.1.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/jasminewd2/-/jasminewd2-2.2.0.tgz#e37cf0b17f199cce23bea71b2039395246b4ec4e" - integrity sha1-43zwsX8ZnM4jvqcbIDk5Uka07E4= - -jquery@^3.3.1: - version "3.3.1" - resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca" - integrity sha512-Ubldcmxp5np52/ENotGxlLe6aGMvmF4R8S6tZjsP6Knsaxd/xp3Zrh50cG93lR6nPXyUFwzN3ZSOQI0wRJNdGg== - -js-base64@^2.1.8, js-base64@^2.4.3: - version "2.4.9" - resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.4.9.tgz#748911fb04f48a60c4771b375cac45a80df11c03" - integrity sha512-xcinL3AuDJk7VSzsHgb9DvvIXayBbadtMZ4HFPx8rUszbW1MuNMlwYVC4zzCZ6e1sqZpnNS5ZFYOhXqA39T7LQ== - -"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" - integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== - -js-tokens@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" - integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= - -js-yaml@3.x, js-yaml@^3.12.0, js-yaml@^3.7.0, js-yaml@^3.9.0: - version "3.12.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.0.tgz#eaed656ec8344f10f527c6bfa1b6e2244de167d1" - integrity sha512-PIt2cnwmPfL4hKNwqeiuz4bKfnzHTBv6HyVgjahA6mPLwPDzjDWrplJBMjHUFxku/N3FlmrbyPclad+I+4mJ3A== - dependencies: - argparse "^1.0.7" - esprima "^4.0.0" - -jsbn@~0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" - integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= - -jsesc@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b" - integrity sha1-RsP+yMGJKxKwgz25vHYiF226s0s= - -jsesc@^2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.1.tgz#e421a2a8e20d6b0819df28908f782526b96dd1fe" - integrity sha1-5CGiqOINawgZ3yiQj3glJrlt0f4= - -jsesc@~0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" - integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0= - -json-buffer@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" - integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg= - -json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" - integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== - -json-schema-traverse@^0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340" - integrity sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A= - -json-schema-traverse@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" - integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== - -json-schema@0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" - integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= - -json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" - integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= - -json3@^3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1" - integrity sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE= - -json5@^0.5.0: - version "0.5.1" - resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" - integrity sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE= - -jsonfile@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" - integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= - optionalDependencies: - graceful-fs "^4.1.6" - -jsonparse@^1.2.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" - integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA= - -jsprim@^1.2.2: - version "1.4.1" - resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" - integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI= - dependencies: - assert-plus "1.0.0" - extsprintf "1.3.0" - json-schema "0.2.3" - verror "1.10.0" - -jszip@^3.1.3: - version "3.1.5" - resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.1.5.tgz#e3c2a6c6d706ac6e603314036d43cd40beefdf37" - integrity sha512-5W8NUaFRFRqTOL7ZDDrx5qWHJyBXy6velVudIzQUSoqAAYqzSh2Z7/m0Rf1QbmQJccegD0r+YZxBjzqoBiEeJQ== - dependencies: - core-js "~2.3.0" - es6-promise "~3.0.2" - lie "~3.1.0" - pako "~1.0.2" - readable-stream "~2.0.6" - -karma-chrome-launcher@~2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-2.2.0.tgz#cf1b9d07136cc18fe239327d24654c3dbc368acf" - integrity sha512-uf/ZVpAabDBPvdPdveyk1EPgbnloPvFFGgmRhYLTDH7gEB4nZdSBk8yTU47w1g/drLSx5uMOkjKk7IWKfWg/+w== - dependencies: - fs-access "^1.0.0" - which "^1.2.1" - -karma-coverage-istanbul-reporter@~2.0.0: - version "2.0.4" - resolved "https://registry.yarnpkg.com/karma-coverage-istanbul-reporter/-/karma-coverage-istanbul-reporter-2.0.4.tgz#402ae4ed6eadb9d9dafbd408ffda17897c0d003a" - integrity sha512-xJS7QSQIVU6VK9HuJ/ieE5yynxKhjCCkd96NLY/BX/HXsx0CskU9JJiMQbd4cHALiddMwI4OWh1IIzeWrsavJw== - dependencies: - istanbul-api "^2.0.5" - minimatch "^3.0.4" - -karma-jasmine-html-reporter@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-0.2.2.tgz#48a8e5ef18807617ee2b5e33c1194c35b439524c" - integrity sha1-SKjl7xiAdhfuK14zwRlMNbQ5Ukw= - dependencies: - karma-jasmine "^1.0.2" - -karma-jasmine@^1.0.2, karma-jasmine@~1.1.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/karma-jasmine/-/karma-jasmine-1.1.2.tgz#394f2b25ffb4a644b9ada6f22d443e2fd08886c3" - integrity sha1-OU8rJf+0pkS5rabyLUQ+L9CIhsM= - -karma-source-map-support@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/karma-source-map-support/-/karma-source-map-support-1.3.0.tgz#36dd4d8ca154b62ace95696236fae37caf0a7dde" - integrity sha512-HcPqdAusNez/ywa+biN4EphGz62MmQyPggUsDfsHqa7tSe4jdsxgvTKuDfIazjL+IOxpVWyT7Pr4dhAV+sxX5Q== - dependencies: - source-map-support "^0.5.5" - -karma@~3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/karma/-/karma-3.0.0.tgz#6da83461a8a28d8224575c3b5b874e271b4730c3" - integrity sha512-ZTjyuDXVXhXsvJ1E4CnZzbCjSxD6sEdzEsFYogLuZM0yqvg/mgz+O+R1jb0J7uAQeuzdY8kJgx6hSNXLwFuHIQ== - dependencies: - bluebird "^3.3.0" - body-parser "^1.16.1" - chokidar "^2.0.3" - colors "^1.1.0" - combine-lists "^1.0.0" - connect "^3.6.0" - core-js "^2.2.0" - di "^0.0.1" - dom-serialize "^2.2.0" - expand-braces "^0.1.1" - glob "^7.1.1" - graceful-fs "^4.1.2" - http-proxy "^1.13.0" - isbinaryfile "^3.0.0" - lodash "^4.17.4" - log4js "^3.0.0" - mime "^2.3.1" - minimatch "^3.0.2" - optimist "^0.6.1" - qjobs "^1.1.4" - range-parser "^1.2.0" - rimraf "^2.6.0" - safe-buffer "^5.0.1" - socket.io "2.1.1" - source-map "^0.6.1" - tmp "0.0.33" - useragent "2.2.1" - -keyv@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.0.0.tgz#44923ba39e68b12a7cec7df6c3268c031f2ef373" - integrity sha512-eguHnq22OE3uVoSYG0LVWNP+4ppamWr9+zWBe1bsNcovIMy6huUJFPgy4mGwCd/rnl3vOLGW1MTlu4c57CT1xA== - dependencies: - json-buffer "3.0.0" - -killable@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892" - integrity sha512-LzqtLKlUwirEUyl/nicirVmNiPvYs7l5n8wOPP7fyJVpUPkvCnW/vuiXGpylGUlnPDnB7311rARzAt3Mhswpjg== - -kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: - version "3.2.2" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" - integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= - dependencies: - is-buffer "^1.1.5" - -kind-of@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" - integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc= - dependencies: - is-buffer "^1.1.5" - -kind-of@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" - integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== - -kind-of@^6.0.0, kind-of@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" - integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA== - -kuler@1.0.x: - version "1.0.0" - resolved "https://registry.yarnpkg.com/kuler/-/kuler-1.0.0.tgz#904ad31c373b781695854d32be33818bf1d60250" - integrity sha512-oyy6pu/yWRjiVfCoJebNUKFL061sNtrs9ejKTbirIwY3oiHmENVCSkHhxDV85Dkm7JYR/czMCBeoM87WilTdSg== - dependencies: - colornames "^1.1.1" - -latest-version@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15" - integrity sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU= - dependencies: - package-json "^4.0.0" - -lcid@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" - integrity sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU= - dependencies: - invert-kv "^1.0.0" - -lcid@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/lcid/-/lcid-2.0.0.tgz#6ef5d2df60e52f82eb228a4c373e8d1f397253cf" - integrity sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA== - dependencies: - invert-kv "^2.0.0" - -less-loader@4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/less-loader/-/less-loader-4.1.0.tgz#2c1352c5b09a4f84101490274fd51674de41363e" - integrity sha512-KNTsgCE9tMOM70+ddxp9yyt9iHqgmSs0yTZc5XH5Wo+g80RWRIYNqE58QJKm/yMud5wZEvz50ugRDuzVIkyahg== - dependencies: - clone "^2.1.1" - loader-utils "^1.1.0" - pify "^3.0.0" - -less-plugin-npm-import@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/less-plugin-npm-import/-/less-plugin-npm-import-2.1.0.tgz#823e6986c93318a98171ca858848b6bead55bf3e" - integrity sha1-gj5phskzGKmBccqFiEi2vq1Vvz4= - dependencies: - promise "~7.0.1" - resolve "~1.1.6" - -less@3.8.1, less@^3.8.0: - version "3.8.1" - resolved "https://registry.yarnpkg.com/less/-/less-3.8.1.tgz#f31758598ef5a1930dd4caefa9e4340641e71e1d" - integrity sha512-8HFGuWmL3FhQR0aH89escFNBQH/nEiYPP2ltDFdQw2chE28Yx2E3lhAIq9Y2saYwLSwa699s4dBVEfCY8Drf7Q== - dependencies: - clone "^2.1.2" - optionalDependencies: - errno "^0.1.1" - graceful-fs "^4.1.2" - image-size "~0.5.0" - mime "^1.4.1" - mkdirp "^0.5.0" - promise "^7.1.1" - request "^2.83.0" - source-map "~0.6.0" - -levn@~0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" - integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= - dependencies: - prelude-ls "~1.1.2" - type-check "~0.3.2" - -license-checker@^20.1.0: - version "20.2.0" - resolved "https://registry.yarnpkg.com/license-checker/-/license-checker-20.2.0.tgz#edb2605fd714514ed16e7eeee5b88a41900241d2" - integrity sha512-bseotvz8lfzEY7siilsJhQd2K/z+IF9aeJ+ZscieyZ70S6Ql2iJdVp1IfnL6gkdU4ny5JctT5Y2lFCQerSCfGA== - dependencies: - chalk "^2.4.1" - debug "^3.1.0" - mkdirp "^0.5.1" - nopt "^4.0.1" - read-installed "~4.0.3" - semver "^5.5.0" - spdx "^0.5.1" - spdx-correct "^3.0.0" - spdx-satisfies "^4.0.0" - strip-ansi "^4.0.0" - treeify "^1.1.0" - -license-webpack-plugin@2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/license-webpack-plugin/-/license-webpack-plugin-2.0.2.tgz#9d34b521cb7fca8527945310b05be6ef0248b687" - integrity sha512-GsomZw5VoT20ST8qH2tOjBgbyhn6Pgs9M94g0mbvfBIV1VXufm1iKY+4dbgfTObj1Mp6nSRE3Zf74deOZr0KwA== - dependencies: - webpack-sources "^1.2.0" - -lie@~3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e" - integrity sha1-mkNrLMd0bKWd56QfpGmz77dr2H4= - dependencies: - immediate "~3.0.5" - -load-json-file@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" - integrity sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA= - dependencies: - graceful-fs "^4.1.2" - parse-json "^2.2.0" - pify "^2.0.0" - pinkie-promise "^2.0.0" - strip-bom "^2.0.0" - -load-json-file@^2.0.0: - version "2.0.0" - resolved "http://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8" - integrity sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg= - dependencies: - graceful-fs "^4.1.2" - parse-json "^2.2.0" - pify "^2.0.0" - strip-bom "^3.0.0" - -load-json-file@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b" - integrity sha1-L19Fq5HjMhYjT9U62rZo607AmTs= - dependencies: - graceful-fs "^4.1.2" - parse-json "^4.0.0" - pify "^3.0.0" - strip-bom "^3.0.0" - -loader-runner@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz#f482aea82d543e07921700d5a46ef26fdac6b8a2" - integrity sha1-9IKuqC1UPgeSFwDVpG7yb9rGuKI= - -loader-utils@1.1.0, loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd" - integrity sha1-yYrvSIvM7aL/teLeZG1qdUQp9c0= - dependencies: - big.js "^3.1.3" - emojis-list "^2.0.0" - json5 "^0.5.0" - -locate-path@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" - integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4= - dependencies: - p-locate "^2.0.0" - path-exists "^3.0.0" - -locate-path@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" - integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== - dependencies: - p-locate "^3.0.0" - path-exists "^3.0.0" - -lodash._reinterpolate@~3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" - integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0= - -lodash.assign@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7" - integrity sha1-DZnzzNem0mHRm9rrkkUAXShYCOc= - -lodash.clonedeep@^4.3.2, lodash.clonedeep@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" - integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= - -lodash.debounce@^4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" - integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= - -lodash.mergewith@^4.6.0: - version "4.6.1" - resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz#639057e726c3afbdb3e7d42741caa8d6e4335927" - integrity sha512-eWw5r+PYICtEBgrBE5hhlT6aAa75f411bgDz/ZL2KZqYV03USvucsxcHUIlGTDTECs1eunpI7HOV7U+WLDvNdQ== - -lodash.tail@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664" - integrity sha1-0jM6NtnncXyK0vfKyv7HwytERmQ= - -lodash.template@^4.0.2: - version "4.4.0" - resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.4.0.tgz#e73a0385c8355591746e020b99679c690e68fba0" - integrity sha1-5zoDhcg1VZF0bgILmWecaQ5o+6A= - dependencies: - lodash._reinterpolate "~3.0.0" - lodash.templatesettings "^4.0.0" - -lodash.templatesettings@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.1.0.tgz#2b4d4e95ba440d915ff08bc899e4553666713316" - integrity sha1-K01OlbpEDZFf8IvImeRVNmZxMxY= - dependencies: - lodash._reinterpolate "~3.0.0" - -lodash@^4.0.0, lodash@^4.17.10, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.1, lodash@^4.5.0, lodash@~4.17.10: - version "4.17.10" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.10.tgz#1b7793cf7259ea38fb3661d4d38b3260af8ae4e7" - integrity sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg== - -log4js@^3.0.0: - version "3.0.5" - resolved "https://registry.yarnpkg.com/log4js/-/log4js-3.0.5.tgz#b80146bfebad68b430d4f3569556d8a6edfef303" - integrity sha512-IX5c3G/7fuTtdr0JjOT2OIR12aTESVhsH6cEsijloYwKgcPRlO6DgOU72v0UFhWcoV1HN6+M3dwT89qVPLXm0w== - dependencies: - circular-json "^0.5.5" - date-format "^1.2.0" - debug "^3.1.0" - rfdc "^1.1.2" - streamroller "0.7.0" - -logform@^1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/logform/-/logform-1.9.1.tgz#58b29d7b11c332456d7a217e17b48a13ad69d60a" - integrity sha512-ZHrZE8VSf7K3xKxJiQ1aoTBp2yK+cEbFcgarsjzI3nt3nE/3O0heNSppoOQMUJVMZo/xiVwCxiXIabaZApsKNQ== - dependencies: - colors "^1.2.1" - fast-safe-stringify "^2.0.4" - fecha "^2.3.3" - ms "^2.1.1" - triple-beam "^1.2.0" - -loglevel@^1.4.1: - version "1.6.1" - resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.1.tgz#e0fc95133b6ef276cdc8887cdaf24aa6f156f8fa" - integrity sha1-4PyVEztu8nbNyIh82vJKpvFW+Po= - -long@~3: - version "3.2.0" - resolved "https://registry.yarnpkg.com/long/-/long-3.2.0.tgz#d821b7138ca1cb581c172990ef14db200b5c474b" - integrity sha1-2CG3E4yhy1gcFymQ7xTbIAtcR0s= - -loose-envify@^1.0.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" - integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== - dependencies: - js-tokens "^3.0.0 || ^4.0.0" - -loud-rejection@^1.0.0, loud-rejection@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f" - integrity sha1-W0b4AUft7leIcPCG0Eghz5mOVR8= - dependencies: - currently-unhandled "^0.4.1" - signal-exit "^3.0.0" - -lowercase-keys@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306" - integrity sha1-TjNms55/VFfjXxMkvfb4jQv8cwY= - -lowercase-keys@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" - integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA== - -lru-cache@2.2.x: - version "2.2.4" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.2.4.tgz#6c658619becf14031d0d0b594b16042ce4dc063d" - integrity sha1-bGWGGb7PFAMdDQtZSxYELOTcBj0= - -lru-cache@^4.0.1, lru-cache@^4.1.1, lru-cache@^4.1.3: - version "4.1.3" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.3.tgz#a1175cf3496dfc8436c156c334b4955992bce69c" - integrity sha512-fFEhvcgzuIoJVUF8fYr5KR0YqxD238zgObTps31YdADwPPAp82a4M8TrckkWyx7ekNlf9aBcVn81cFwwXngrJA== - dependencies: - pseudomap "^1.0.2" - yallist "^2.1.2" - -magic-string@^0.22.4: - version "0.22.5" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.22.5.tgz#8e9cf5afddf44385c1da5bc2a6a0dbd10b03657e" - integrity sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w== - dependencies: - vlq "^0.2.2" - -magic-string@^0.25.0: - version "0.25.0" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.0.tgz#1f3696f9931ff0a1ed4c132250529e19cad6759b" - integrity sha512-Msbwa9oNYNPjwVh9ury5X2BHbTFWoirTlzuf4X+pIoSOQVKNRJHXTx1WmKYuXzRM4QZFv8dGXyZvhDMmWhGLPw== - dependencies: - sourcemap-codec "^1.4.1" - -make-dir@^1.0.0, make-dir@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c" - integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ== - dependencies: - pify "^3.0.0" - -make-error@^1.1.1: - version "1.3.5" - resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.5.tgz#efe4e81f6db28cadd605c70f29c831b58ef776c8" - integrity sha512-c3sIjNUow0+8swNwVpqoH4YCShKNFkMaw6oH1mNS2haDZQqkeZFlHS3dhoeEbKKmJB4vXpJucU6oH75aDYeE9g== - -map-age-cleaner@^0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.2.tgz#098fb15538fd3dbe461f12745b0ca8568d4e3f74" - integrity sha512-UN1dNocxQq44IhJyMI4TU8phc2m9BddacHRPRjKGLYaF0jqd3xLz0jS0skpAU9WgYyoR4gHtUpzytNBS385FWQ== - dependencies: - p-defer "^1.0.0" - -map-cache@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" - integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= - -map-obj@^1.0.0, map-obj@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" - integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0= - -map-obj@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-2.0.0.tgz#a65cd29087a92598b8791257a523e021222ac1f9" - integrity sha1-plzSkIepJZi4eRJXpSPgISIqwfk= - -map-visit@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" - integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48= - dependencies: - object-visit "^1.0.0" - -material-design-icons@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/material-design-icons/-/material-design-icons-3.0.1.tgz#9a71c48747218ebca51e51a66da682038cdcb7bf" - integrity sha1-mnHEh0chjrylHlGmbaaCA4zct78= - -math-random@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.1.tgz#8b3aac588b8a66e4975e3cdea67f7bb329601fac" - integrity sha1-izqsWIuKZuSXXjzepn97sylgH6w= - -md5.js@^1.3.4: - version "1.3.4" - resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.4.tgz#e9bdbde94a20a5ac18b04340fc5764d5b09d901d" - integrity sha1-6b296UogpawYsENA/Fdk1bCdkB0= - dependencies: - hash-base "^3.0.0" - inherits "^2.0.1" - -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= - -mem@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76" - integrity sha1-Xt1StIXKHZAP5kiVUFOZoN+kX3Y= - dependencies: - mimic-fn "^1.0.0" - -mem@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/mem/-/mem-4.0.0.tgz#6437690d9471678f6cc83659c00cbafcd6b0cdaf" - integrity sha512-WQxG/5xYc3tMbYLXoXPm81ET2WDULiU5FxbuIoNbJqLOOI8zehXFdZuiUEgfdrU2mVB1pxBZUGlYORSrpuJreA== - dependencies: - map-age-cleaner "^0.1.1" - mimic-fn "^1.0.0" - p-is-promise "^1.1.0" - -memory-fs@^0.4.0, memory-fs@~0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" - integrity sha1-OpoguEYlI+RHz7x+i7gO1me/xVI= - dependencies: - errno "^0.1.3" - readable-stream "^2.0.1" - -meow@^3.3.0, meow@^3.7.0: - version "3.7.0" - resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb" - integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs= - dependencies: - camelcase-keys "^2.0.0" - decamelize "^1.1.2" - loud-rejection "^1.0.0" - map-obj "^1.0.1" - minimist "^1.1.3" - normalize-package-data "^2.3.4" - object-assign "^4.0.1" - read-pkg-up "^1.0.1" - redent "^1.0.0" - trim-newlines "^1.0.0" - -meow@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/meow/-/meow-4.0.1.tgz#d48598f6f4b1472f35bf6317a95945ace347f975" - integrity sha512-xcSBHD5Z86zaOc+781KrupuHAzeGXSLtiAOmBsiLDiPSaYSB6hdew2ng9EBAnZ62jagG9MHAOdxpDi/lWBFJ/A== - dependencies: - camelcase-keys "^4.0.0" - decamelize-keys "^1.0.0" - loud-rejection "^1.0.0" - minimist "^1.1.3" - minimist-options "^3.0.1" - normalize-package-data "^2.3.4" - read-pkg-up "^3.0.0" - redent "^2.0.0" - trim-newlines "^2.0.0" - -merge-descriptors@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" - integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= - -merge-source-map@1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/merge-source-map/-/merge-source-map-1.0.4.tgz#a5de46538dae84d4114cc5ea02b4772a6346701f" - integrity sha1-pd5GU42uhNQRTMXqArR3KmNGcB8= - dependencies: - source-map "^0.5.6" - -methods@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= - -micromatch@^2.1.5, micromatch@^2.3.11: - version "2.3.11" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565" - integrity sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU= - dependencies: - arr-diff "^2.0.0" - array-unique "^0.2.1" - braces "^1.8.2" - expand-brackets "^0.1.4" - extglob "^0.3.1" - filename-regex "^2.0.0" - is-extglob "^1.0.0" - is-glob "^2.0.1" - kind-of "^3.0.2" - normalize-path "^2.0.1" - object.omit "^2.0.0" - parse-glob "^3.0.4" - regex-cache "^0.4.2" - -micromatch@^3.1.4, micromatch@^3.1.8, micromatch@^3.1.9: - version "3.1.10" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" - integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== - dependencies: - arr-diff "^4.0.0" - array-unique "^0.3.2" - braces "^2.3.1" - define-property "^2.0.2" - extend-shallow "^3.0.2" - extglob "^2.0.4" - fragment-cache "^0.2.1" - kind-of "^6.0.2" - nanomatch "^1.2.9" - object.pick "^1.3.0" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.2" - -miller-rabin@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" - integrity sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA== - dependencies: - bn.js "^4.0.0" - brorand "^1.0.1" - -"mime-db@>= 1.34.0 < 2", mime-db@~1.36.0: - version "1.36.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.36.0.tgz#5020478db3c7fe93aad7bbcc4dcf869c43363397" - integrity sha512-L+xvyD9MkoYMXb1jAmzI/lWYAxAMCPvIBSWur0PZ5nOf5euahRLVqH//FKW9mWp2lkqUgYiXPgkzfMUFi4zVDw== - -mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.18, mime-types@~2.1.19: - version "2.1.20" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.20.tgz#930cb719d571e903738520f8470911548ca2cc19" - integrity sha512-HrkrPaP9vGuWbLK1B1FfgAkbqNjIuy4eHlIYnFi7kamZyLLrGlo2mpcx0bBmNpKqBtYtAfGbodDddIgddSJC2A== - dependencies: - mime-db "~1.36.0" - -mime@1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6" - integrity sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ== - -mime@^1.4.1: - version "1.6.0" - resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" - integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== - -mime@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/mime/-/mime-2.3.1.tgz#b1621c54d63b97c47d3cfe7f7215f7d64517c369" - integrity sha512-OEUllcVoydBHGN1z84yfQDimn58pZNNNXgZlHXSboxMlFvgI6MXSWpWKpFRra7H1HxpVhHTkrghfRW49k6yjeg== - -mimic-fn@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" - integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ== - -mimic-response@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" - integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== - -mini-css-extract-plugin@0.4.4: - version "0.4.4" - resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.4.4.tgz#c10410a004951bd3cedac1da69053940fccb625d" - integrity sha512-o+Jm+ocb0asEngdM6FsZWtZsRzA8koFUudIDwYUfl94M3PejPHG7Vopw5hN9V8WsMkSFpm3tZP3Fesz89EyrfQ== - dependencies: - loader-utils "^1.1.0" - schema-utils "^1.0.0" - webpack-sources "^1.1.0" - -minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" - integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== - -minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" - integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= - -"minimatch@2 || 3", minimatch@3.0.4, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4, minimatch@~3.0.2: - version "3.0.4" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" - integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== - dependencies: - brace-expansion "^1.1.7" - -minimist-options@^3.0.1: - version "3.0.2" - resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-3.0.2.tgz#fba4c8191339e13ecf4d61beb03f070103f3d954" - integrity sha512-FyBrT/d0d4+uiZRbqznPXqw3IpZZG3gl3wKWiX784FycUKVwBt0uLBFkQrtE4tZOrgo78nZp2jnKz3L65T5LdQ== - dependencies: - arrify "^1.0.1" - is-plain-obj "^1.1.0" - -minimist@0.0.8: - version "0.0.8" - resolved "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" - integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= - -minimist@1.2.0, minimist@^1.1.3, minimist@^1.2.0: - version "1.2.0" - resolved "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" - integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ= - -minimist@~0.0.1: - version "0.0.10" - resolved "http://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" - integrity sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8= - -minipass@^2.2.1, minipass@^2.3.3: - version "2.3.4" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.4.tgz#4768d7605ed6194d6d576169b9e12ef71e9d9957" - integrity sha512-mlouk1OHlaUE8Odt1drMtG1bAJA4ZA6B/ehysgV0LUIrDHdKgo1KorZq3pK0b/7Z7LJIQ12MNM6aC+Tn6lUZ5w== - dependencies: - safe-buffer "^5.1.2" - yallist "^3.0.0" - -minizlib@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.1.0.tgz#11e13658ce46bc3a70a267aac58359d1e0c29ceb" - integrity sha512-4T6Ur/GctZ27nHfpt9THOdRZNgyJ9FZchYO1ceg5S8Q3DNLCKYy44nCZzgCJgcvx2UM8czmqak5BCxJMrq37lA== - dependencies: - minipass "^2.2.1" - -mississippi@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-2.0.0.tgz#3442a508fafc28500486feea99409676e4ee5a6f" - integrity sha512-zHo8v+otD1J10j/tC+VNoGK9keCuByhKovAvdn74dmxJl9+mWHnx6EMsDN4lgRoMI/eYo2nchAxniIbUPb5onw== - dependencies: - concat-stream "^1.5.0" - duplexify "^3.4.2" - end-of-stream "^1.1.0" - flush-write-stream "^1.0.0" - from2 "^2.1.0" - parallel-transform "^1.1.0" - pump "^2.0.1" - pumpify "^1.3.3" - stream-each "^1.1.0" - through2 "^2.0.0" - -mississippi@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-3.0.0.tgz#ea0a3291f97e0b5e8776b363d5f0a12d94c67022" - integrity sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA== - dependencies: - concat-stream "^1.5.0" - duplexify "^3.4.2" - end-of-stream "^1.1.0" - flush-write-stream "^1.0.0" - from2 "^2.1.0" - parallel-transform "^1.1.0" - pump "^3.0.0" - pumpify "^1.3.3" - stream-each "^1.1.0" - through2 "^2.0.0" - -mixin-deep@^1.2.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe" - integrity sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ== - dependencies: - for-in "^1.0.2" - is-extendable "^1.0.1" - -mixin-object@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/mixin-object/-/mixin-object-2.0.1.tgz#4fb949441dab182540f1fe035ba60e1947a5e57e" - integrity sha1-T7lJRB2rGCVA8f4DW6YOGUel5X4= - dependencies: - for-in "^0.1.3" - is-extendable "^0.1.1" - -mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0: - version "0.5.1" - resolved "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" - integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= - dependencies: - minimist "0.0.8" - -modify-values@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" - integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== - -move-concurrently@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" - integrity sha1-viwAX9oy4LKa8fBdfEszIUxwH5I= - dependencies: - aproba "^1.1.1" - copy-concurrently "^1.0.0" - fs-write-stream-atomic "^1.0.8" - mkdirp "^0.5.1" - rimraf "^2.5.4" - run-queue "^1.0.3" - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= - -ms@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" - integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== - -multicast-dns-service-types@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901" - integrity sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE= - -multicast-dns@^6.0.1: - version "6.2.3" - resolved "https://registry.yarnpkg.com/multicast-dns/-/multicast-dns-6.2.3.tgz#a0ec7bd9055c4282f790c3c82f4e28db3b31b229" - integrity sha512-ji6J5enbMyGRHIAkAOu3WdV8nggqviKCEKtXcOqfphZZtQrmHKycfynJ2V7eVPUA4NhJ6V7Wf4TmGbTwKE9B6g== - dependencies: - dns-packet "^1.3.1" - thunky "^1.0.2" - -mute-stream@0.0.7: - version "0.0.7" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" - integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s= - -nan@^2.10.0, nan@^2.9.2: - version "2.11.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.11.0.tgz#574e360e4d954ab16966ec102c0c049fd961a099" - integrity sha512-F4miItu2rGnV2ySkXOQoA8FKz/SR2Q2sWP0sbTxNxz/tuokeC8WxOhPMcwi0qIyGtVn/rrSeLbvVkznqCdwYnw== - -nanomatch@^1.2.9: - version "1.2.13" - resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" - integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA== - dependencies: - arr-diff "^4.0.0" - array-unique "^0.3.2" - define-property "^2.0.2" - extend-shallow "^3.0.2" - fragment-cache "^0.2.1" - is-windows "^1.0.2" - kind-of "^6.0.2" - object.pick "^1.3.0" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.1" - -needle@^2.2.1: - version "2.2.3" - resolved "https://registry.yarnpkg.com/needle/-/needle-2.2.3.tgz#c1b04da378cd634d8befe2de965dc2cfb0fd65ca" - integrity sha512-GPL22d/U9cai87FcCPO6e+MT3vyHS2j+zwotakDc7kE2DtUAqFKMXLJCTtRp+5S75vXIwQPvIxkvlctxf9q4gQ== - dependencies: - debug "^2.1.2" - iconv-lite "^0.4.4" - sax "^1.2.4" - -negotiator@0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" - integrity sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk= - -neo-async@^2.5.0: - version "2.5.2" - resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.5.2.tgz#489105ce7bc54e709d736b195f82135048c50fcc" - integrity sha512-vdqTKI9GBIYcAEbFAcpKPErKINfPF5zIuz3/niBfq8WUZjpT2tytLlFVrBgWdOtqI4uaA/Rb6No0hux39XXDuw== - -ng-packagr@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/ng-packagr/-/ng-packagr-4.2.0.tgz#9e2babac0402f761c64908996dae99e54a07fe3e" - integrity sha512-y1a9DgIMho5bG77WWK/3Tz2Sxe3Vm9zt4c3AMTLFA6+GqukgTHJYwdfcvWzNfi3iPotoAeoMBCKwd5KSOoiU8A== - dependencies: - "@ngtools/json-schema" "^1.1.0" - autoprefixer "^9.0.0" - browserslist "^4.0.0" - chalk "^2.3.1" - chokidar "^2.0.3" - clean-css "^4.1.11" - commander "^2.12.0" - fs-extra "^7.0.0" - glob "^7.1.2" - injection-js "^2.2.1" - less "^3.8.0" - less-plugin-npm-import "^2.1.0" - node-sass "^4.9.3" - node-sass-tilde-importer "^1.0.0" - postcss "^7.0.0" - postcss-url "^8.0.0" - read-pkg-up "^4.0.0" - rimraf "^2.6.1" - rollup "^0.66.0" - rollup-plugin-commonjs "^9.1.3" - rollup-plugin-node-resolve "^3.0.0" - rollup-plugin-sourcemaps "^0.4.2" - rxjs "^6.0.0" - stylus "^0.54.5" - uglify-js "^3.0.7" - update-notifier "^2.3.0" - -nice-try@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" - integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== - -node-fetch@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.2.0.tgz#4ee79bde909262f9775f731e3656d0db55ced5b5" - integrity sha512-OayFWziIxiHY8bCUyLX6sTpDH8Jsbp4FfYd1j1f7vZyfgkcOnAyM4oQR16f8a0s7Gl/viMGRey8eScYk4V4EZA== - -node-forge@0.7.5: - version "0.7.5" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.5.tgz#6c152c345ce11c52f465c2abd957e8639cd674df" - integrity sha512-MmbQJ2MTESTjt3Gi/3yG1wGpIMhUfcIypUCGtTizFR9IiccFwxSpfp0vtIZlkFclEqERemxfnSdZEMR9VqqEFQ== - -node-gyp@^3.8.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.8.0.tgz#540304261c330e80d0d5edce253a68cb3964218c" - integrity sha512-3g8lYefrRRzvGeSowdJKAKyks8oUpLEd/DyPV4eMhVlhJ0aNaZqIrNUIPuEWWTAoPqyFkfGrM67MC69baqn6vA== - dependencies: - fstream "^1.0.0" - glob "^7.0.3" - graceful-fs "^4.1.2" - mkdirp "^0.5.0" - nopt "2 || 3" - npmlog "0 || 1 || 2 || 3 || 4" - osenv "0" - request "^2.87.0" - rimraf "2" - semver "~5.3.0" - tar "^2.0.0" - which "1" - -node-libs-browser@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.1.0.tgz#5f94263d404f6e44767d726901fff05478d600df" - integrity sha512-5AzFzdoIMb89hBGMZglEegffzgRg+ZFoUmisQ8HI4j1KDdpx13J0taNp2y9xPbur6W61gepGDDotGBVQ7mfUCg== - dependencies: - assert "^1.1.1" - browserify-zlib "^0.2.0" - buffer "^4.3.0" - console-browserify "^1.1.0" - constants-browserify "^1.0.0" - crypto-browserify "^3.11.0" - domain-browser "^1.1.1" - events "^1.0.0" - https-browserify "^1.0.0" - os-browserify "^0.3.0" - path-browserify "0.0.0" - process "^0.11.10" - punycode "^1.2.4" - querystring-es3 "^0.2.0" - readable-stream "^2.3.3" - stream-browserify "^2.0.1" - stream-http "^2.7.2" - string_decoder "^1.0.0" - timers-browserify "^2.0.4" - tty-browserify "0.0.0" - url "^0.11.0" - util "^0.10.3" - vm-browserify "0.0.4" - -node-pre-gyp@^0.10.0: - version "0.10.3" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz#3070040716afdc778747b61b6887bf78880b80fc" - integrity sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4" - -node-releases@^1.0.0-alpha.11: - version "1.0.0-alpha.11" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.0.0-alpha.11.tgz#73c810acc2e5b741a17ddfbb39dfca9ab9359d8a" - integrity sha512-CaViu+2FqTNYOYNihXa5uPS/zry92I3vPU4nCB6JB3OeZ2UGtOpF5gRwuN4+m3hbEcL47bOXyun1jX2iC+3uEQ== - dependencies: - semver "^5.3.0" - -node-sass-tilde-importer@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/node-sass-tilde-importer/-/node-sass-tilde-importer-1.0.2.tgz#1a15105c153f648323b4347693fdb0f331bad1ce" - integrity sha512-Swcmr38Y7uB78itQeBm3mThjxBy9/Ah/ykPIaURY/L6Nec9AyRoL/jJ7ECfMR+oZeCTVQNxVMu/aHU+TLRVbdg== - dependencies: - find-parent-dir "^0.3.0" - -node-sass@4.9.3, node-sass@^4.9.3: - version "4.9.3" - resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.9.3.tgz#f407cf3d66f78308bb1e346b24fa428703196224" - integrity sha512-XzXyGjO+84wxyH7fV6IwBOTrEBe2f0a6SBze9QWWYR/cL74AcQUks2AsqcCZenl/Fp/JVbuEaLpgrLtocwBUww== - dependencies: - async-foreach "^0.1.3" - chalk "^1.1.1" - cross-spawn "^3.0.0" - gaze "^1.0.0" - get-stdin "^4.0.1" - glob "^7.0.3" - in-publish "^2.0.0" - lodash.assign "^4.2.0" - lodash.clonedeep "^4.3.2" - lodash.mergewith "^4.6.0" - meow "^3.7.0" - mkdirp "^0.5.1" - nan "^2.10.0" - node-gyp "^3.8.0" - npmlog "^4.0.0" - request "2.87.0" - sass-graph "^2.2.4" - stdout-stream "^1.4.0" - "true-case-path" "^1.0.2" - -"nopt@2 || 3", nopt@3.x: - version "3.0.6" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" - integrity sha1-xkZdvwirzU2zWTF/eaxopkayj/k= - dependencies: - abbrev "1" - -nopt@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" - integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00= - dependencies: - abbrev "1" - osenv "^0.1.4" - -normalize-package-data@^2.0.0, normalize-package-data@^2.3.0, normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.3.5, "normalize-package-data@~1.0.1 || ^2.0.0": - version "2.4.0" - resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f" - integrity sha512-9jjUFbTPfEy3R/ad/2oNbKtW9Hgovl5O1FvFWKkKblNXoN/Oou6+9+KKohPK13Yc3/TyunyWhJp6gvRNR/PPAw== - dependencies: - hosted-git-info "^2.1.4" - is-builtin-module "^1.0.0" - semver "2 || 3 || 4 || 5" - validate-npm-package-license "^3.0.1" - -normalize-path@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-1.0.0.tgz#32d0e472f91ff345701c15a8311018d3b0a90379" - integrity sha1-MtDkcvkf80VwHBWoMRAY07CpA3k= - -normalize-path@^2.0.0, normalize-path@^2.0.1, normalize-path@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" - integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= - dependencies: - remove-trailing-separator "^1.0.1" - -normalize-range@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" - integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= - -normalize-url@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-2.0.1.tgz#835a9da1551fa26f70e92329069a23aa6574d7e6" - integrity sha512-D6MUW4K/VzoJ4rJ01JFKxDrtY1v9wrgzCX5f2qj/lzH1m/lW6MhUZFKerVsnyjOhOsYzI9Kqqak+10l4LvLpMw== - dependencies: - prepend-http "^2.0.0" - query-string "^5.0.1" - sort-keys "^2.0.0" - -npm-bundled@^1.0.1: - version "1.0.5" - resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.5.tgz#3c1732b7ba936b3a10325aef616467c0ccbcc979" - integrity sha512-m/e6jgWu8/v5niCUKQi9qQl8QdeEduFA96xHDDzFGqly0OOjI7c+60KM/2sppfnUU9JJagf+zs+yGhqSOFj71g== - -"npm-package-arg@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0": - version "6.1.0" - resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-6.1.0.tgz#15ae1e2758a5027efb4c250554b85a737db7fcc1" - integrity sha512-zYbhP2k9DbJhA0Z3HKUePUgdB1x7MfIfKssC+WLPFMKTBZKpZh5m13PgexJjCq6KW7j17r0jHWcCpxEqnnncSA== - dependencies: - hosted-git-info "^2.6.0" - osenv "^0.1.5" - semver "^5.5.0" - validate-npm-package-name "^3.0.0" - -npm-packlist@^1.1.6: - version "1.1.11" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.11.tgz#84e8c683cbe7867d34b1d357d893ce29e28a02de" - integrity sha512-CxKlZ24urLkJk+9kCm48RTQ7L4hsmgSVzEk0TLGPzzyuFxD7VNgy5Sl24tOLMzQv773a/NeJ1ce1DKeacqffEA== - dependencies: - ignore-walk "^3.0.1" - npm-bundled "^1.0.1" - -npm-registry-client@8.6.0: - version "8.6.0" - resolved "https://registry.yarnpkg.com/npm-registry-client/-/npm-registry-client-8.6.0.tgz#7f1529f91450732e89f8518e0f21459deea3e4c4" - integrity sha512-Qs6P6nnopig+Y8gbzpeN/dkt+n7IyVd8f45NTMotGk6Qo7GfBmzwYx6jRLoOOgKiMnaQfYxsuyQlD8Mc3guBhg== - dependencies: - concat-stream "^1.5.2" - graceful-fs "^4.1.6" - normalize-package-data "~1.0.1 || ^2.0.0" - npm-package-arg "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" - once "^1.3.3" - request "^2.74.0" - retry "^0.10.0" - safe-buffer "^5.1.1" - semver "2 >=2.2.1 || 3.x || 4 || 5" - slide "^1.1.3" - ssri "^5.2.4" - optionalDependencies: - npmlog "2 || ^3.1.0 || ^4.0.0" - -npm-run-path@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" - integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8= - dependencies: - path-key "^2.0.0" - -"npmlog@0 || 1 || 2 || 3 || 4", "npmlog@2 || ^3.1.0 || ^4.0.0", npmlog@^4.0.0, npmlog@^4.0.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" - integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== - dependencies: - are-we-there-yet "~1.1.2" - console-control-strings "~1.1.0" - gauge "~2.7.3" - set-blocking "~2.0.0" - -null-check@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/null-check/-/null-check-1.0.0.tgz#977dffd7176012b9ec30d2a39db5cf72a0439edd" - integrity sha1-l33/1xdgErnsMNKjnbXPcqBDnt0= - -num2fraction@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede" - integrity sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4= - -number-is-nan@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" - integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= - -oauth-sign@~0.8.2: - version "0.8.2" - resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" - integrity sha1-Rqarfwrq2N6unsBWV4C31O/rnUM= - -oauth-sign@~0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" - integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== - -object-assign@^4.0.1, object-assign@^4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= - -object-component@0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/object-component/-/object-component-0.0.3.tgz#f0c69aa50efc95b866c186f400a33769cb2f1291" - integrity sha1-8MaapQ78lbhmwYb0AKM3acsvEpE= - -object-copy@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" - integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw= - dependencies: - copy-descriptor "^0.1.0" - define-property "^0.2.5" - kind-of "^3.0.3" - -object-inspect@~1.4.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.4.1.tgz#37ffb10e71adaf3748d05f713b4c9452f402cbc4" - integrity sha512-wqdhLpfCUbEsoEwl3FXwGyv8ief1k/1aUdIPCqVnupM6e8l63BEJdiF/0swtn04/8p05tG/T0FrpTlfwvljOdw== - -object-keys@^1.0.6: - version "1.0.12" - resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.12.tgz#09c53855377575310cca62f55bb334abff7b3ed2" - integrity sha512-FTMyFUm2wBcGHnH2eXmz7tC6IwlqQZ6mVZ+6dm6vZ4IQIHjs6FdNsQBuKGPuUUUY6NfJw2PshC08Tn6LzLDOag== - -object-visit@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" - integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs= - dependencies: - isobject "^3.0.0" - -object.omit@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa" - integrity sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo= - dependencies: - for-own "^0.1.4" - is-extendable "^0.1.1" - -object.pick@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" - integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c= - dependencies: - isobject "^3.0.1" - -obuf@^1.0.0, obuf@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" - integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== - -on-finished@~2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" - integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= - dependencies: - ee-first "1.1.1" - -on-headers@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.1.tgz#928f5d0f470d49342651ea6794b0857c100693f7" - integrity sha1-ko9dD0cNSTQmUepnlLCFfBAGk/c= - -once@1.x, once@^1.3.0, once@^1.3.1, once@^1.3.3, once@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= - dependencies: - wrappy "1" - -one-time@0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/one-time/-/one-time-0.0.4.tgz#f8cdf77884826fe4dff93e3a9cc37b1e4480742e" - integrity sha1-+M33eISCb+Tf+T46nMN7HkSAdC4= - -onetime@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" - integrity sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ= - dependencies: - mimic-fn "^1.0.0" - -opn@5.3.0, opn@^5.1.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/opn/-/opn-5.3.0.tgz#64871565c863875f052cfdf53d3e3cb5adb53b1c" - integrity sha512-bYJHo/LOmoTd+pfiYhfZDnf9zekVJrY+cnS2a5F2x+w5ppvTqObojTP7WiFG+kVZs9Inw+qQ/lw7TroWwhdd2g== - dependencies: - is-wsl "^1.1.0" - -optimist@^0.6.1, optimist@~0.6.0: - version "0.6.1" - resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686" - integrity sha1-2j6nRob6IaGaERwybpDrFaAZZoY= - dependencies: - minimist "~0.0.1" - wordwrap "~0.0.2" - -optionator@^0.8.1: - version "0.8.2" - resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64" - integrity sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q= - dependencies: - deep-is "~0.1.3" - fast-levenshtein "~2.0.4" - levn "~0.3.0" - prelude-ls "~1.1.2" - type-check "~0.3.2" - wordwrap "~1.0.0" - -optjs@~3.2.2: - version "3.2.2" - resolved "https://registry.yarnpkg.com/optjs/-/optjs-3.2.2.tgz#69a6ce89c442a44403141ad2f9b370bd5bb6f4ee" - integrity sha1-aabOicRCpEQDFBrS+bNwvVu29O4= - -original@>=0.0.5, original@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/original/-/original-1.0.2.tgz#e442a61cffe1c5fd20a65f3261c26663b303f25f" - integrity sha512-hyBVl6iqqUOJ8FqRe+l/gS8H+kKYjrEndd5Pm1MfBtsEKA038HkkdbAl/72EAXGyonD/PFsvmVG+EvcIpliMBg== - dependencies: - url-parse "^1.4.3" - -os-browserify@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" - integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= - -os-homedir@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" - integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= - -os-locale@^1.4.0: - version "1.4.0" - resolved "http://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz#20f9f17ae29ed345e8bde583b13d2009803c14d9" - integrity sha1-IPnxeuKe00XoveWDsT0gCYA8FNk= - dependencies: - lcid "^1.0.0" - -os-locale@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-2.1.0.tgz#42bc2900a6b5b8bd17376c8e882b65afccf24bf2" - integrity sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA== - dependencies: - execa "^0.7.0" - lcid "^1.0.0" - mem "^1.1.0" - -os-locale@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-3.0.1.tgz#3b014fbf01d87f60a1e5348d80fe870dc82c4620" - integrity sha512-7g5e7dmXPtzcP4bgsZ8ixDVqA7oWYuEz4lOSujeWyliPai4gfVDiFIcwBg3aGCPnmSGfzOKTK3ccPn0CKv3DBw== - dependencies: - execa "^0.10.0" - lcid "^2.0.0" - mem "^4.0.0" - -os-tmpdir@^1.0.0, os-tmpdir@~1.0.1, os-tmpdir@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" - integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= - -osenv@0, osenv@^0.1.4, osenv@^0.1.5: - version "0.1.5" - resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410" - integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g== - dependencies: - os-homedir "^1.0.0" - os-tmpdir "^1.0.0" - -p-cancelable@^0.4.0: - version "0.4.1" - resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.4.1.tgz#35f363d67d52081c8d9585e37bcceb7e0bbcb2a0" - integrity sha512-HNa1A8LvB1kie7cERyy21VNeHb2CWJJYqyyC2o3klWFfMGlFmWv2Z7sFgZH8ZiaYL95ydToKTFVXgMV/Os0bBQ== - -p-defer@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" - integrity sha1-n26xgvbJqozXQwBKfU+WsZaw+ww= - -p-finally@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" - integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= - -p-is-promise@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-1.1.0.tgz#9c9456989e9f6588017b0434d56097675c3da05e" - integrity sha1-nJRWmJ6fZYgBewQ01WCXZ1w9oF4= - -p-limit@^1.0.0, p-limit@^1.1.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" - integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q== - dependencies: - p-try "^1.0.0" - -p-limit@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.0.0.tgz#e624ed54ee8c460a778b3c9f3670496ff8a57aec" - integrity sha512-fl5s52lI5ahKCernzzIyAP0QAZbGIovtVHGwpcu1Jr/EpzLVDI2myISHwGqK7m8uQFugVWSrbxH7XnhGtvEc+A== - dependencies: - p-try "^2.0.0" - -p-locate@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" - integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM= - dependencies: - p-limit "^1.1.0" - -p-locate@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" - integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== - dependencies: - p-limit "^2.0.0" - -p-map@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" - integrity sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA== - -p-timeout@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-2.0.1.tgz#d8dd1979595d2dc0139e1fe46b8b646cb3cdf038" - integrity sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA== - dependencies: - p-finally "^1.0.0" - -p-try@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" - integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M= - -p-try@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.0.0.tgz#85080bb87c64688fa47996fe8f7dfbe8211760b1" - integrity sha512-hMp0onDKIajHfIkdRk3P4CdCmErkYAxxDtP3Wx/4nZ3aGlau2VKh3mZpcuFkH27WQkL/3WBCPOktzA9ZOAnMQQ== - -package-json@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/package-json/-/package-json-4.0.1.tgz#8869a0401253661c4c4ca3da6c2121ed555f5eed" - integrity sha1-iGmgQBJTZhxMTKPabCEh7VVfXu0= - dependencies: - got "^6.7.1" - registry-auth-token "^3.0.1" - registry-url "^3.0.3" - semver "^5.1.0" - -pako@^0.2.5: - version "0.2.9" - resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" - integrity sha1-8/dSL073gjSNqBYbrZ7P1Rv4OnU= - -pako@^1.0.6, pako@~1.0.2, pako@~1.0.5: - version "1.0.6" - resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.6.tgz#0101211baa70c4bca4a0f63f2206e97b7dfaf258" - integrity sha512-lQe48YPsMJAig+yngZ87Lus+NF+3mtu7DVOBu6b/gHO1YpKwIj5AWjZ/TOS7i46HD/UixzWb1zeWDZfGZ3iYcg== - -parallel-transform@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.1.0.tgz#d410f065b05da23081fcd10f28854c29bda33b06" - integrity sha1-1BDwZbBdojCB/NEPKIVMKb2jOwY= - dependencies: - cyclist "~0.2.2" - inherits "^2.0.3" - readable-stream "^2.1.5" - -parse-asn1@^5.0.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.1.tgz#f6bf293818332bd0dab54efb16087724745e6ca8" - integrity sha512-KPx7flKXg775zZpnp9SxJlz00gTd4BmJ2yJufSc44gMCRrRQ7NSzAcSJQfifuOLgW6bEi+ftrALtsgALeB2Adw== - dependencies: - asn1.js "^4.0.0" - browserify-aes "^1.0.0" - create-hash "^1.1.0" - evp_bytestokey "^1.0.0" - pbkdf2 "^3.0.3" - -parse-github-repo-url@^1.3.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/parse-github-repo-url/-/parse-github-repo-url-1.4.1.tgz#9e7d8bb252a6cb6ba42595060b7bf6df3dbc1f50" - integrity sha1-nn2LslKmy2ukJZUGC3v23z28H1A= - -parse-glob@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c" - integrity sha1-ssN2z7EfNVE7rdFz7wu246OIORw= - dependencies: - glob-base "^0.3.0" - is-dotfile "^1.0.0" - is-extglob "^1.0.0" - is-glob "^2.0.0" - -parse-json@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9" - integrity sha1-9ID0BDTvgHQfhGkJn43qGPVaTck= - dependencies: - error-ex "^1.2.0" - -parse-json@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" - integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA= - dependencies: - error-ex "^1.3.1" - json-parse-better-errors "^1.0.1" - -parse5-html-rewriting-stream@5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-5.1.0.tgz#63f92c958764aea8cbe3aa68f2e5895c32068ab8" - integrity sha512-hZlaOTHJs4dpC5yFHnOSEMh26ErsqTsLzxKqkuRcUqtiu0kMsBQ9pkgANkECDyoBUhJ3+hvb2DPLsyqbtcS61Q== - dependencies: - parse5 "^5.1.0" - parse5-sax-parser "^5.1.0" - -parse5-sax-parser@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/parse5-sax-parser/-/parse5-sax-parser-5.1.0.tgz#ec234d23223a8054a74ff173aa3dc2da74c5bbd9" - integrity sha512-VEhdEDhBkoSILPmsZ96SoIIUow3hZbtgQsqXw7r8DxxnqsCIO0fwkT9mWgBcf9SPjVUh92liuEprHrrYzXBPWQ== - dependencies: - parse5 "^5.1.0" - -parse5@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608" - integrity sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA== - -parse5@^5.0.0, parse5@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.0.tgz#c59341c9723f414c452975564c7c00a68d58acd2" - integrity sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ== - -parseqs@0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d" - integrity sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0= - dependencies: - better-assert "~1.0.0" - -parser-toolkit@>=0.0.3: - version "0.0.5" - resolved "https://registry.yarnpkg.com/parser-toolkit/-/parser-toolkit-0.0.5.tgz#ec4b61729c86318b56ea971bfba6b3c672d62c01" - integrity sha1-7EthcpyGMYtW6pcb+6azxnLWLAE= - -parseuri@0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.5.tgz#80204a50d4dbb779bfdc6ebe2778d90e4bce320a" - integrity sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo= - dependencies: - better-assert "~1.0.0" - -parseurl@~1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz#fc289d4ed8993119460c156253262cdc8de65bf3" - integrity sha1-/CidTtiZMRlGDBViUyYs3I3mW/M= - -pascalcase@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" - integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= - -path-browserify@0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.0.tgz#a0b870729aae214005b7d5032ec2cbbb0fb4451a" - integrity sha1-oLhwcpquIUAFt9UDLsLLuw+0RRo= - -path-dirname@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" - integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA= - -path-exists@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b" - integrity sha1-D+tsZPD8UY2adU3V77YscCJ2H0s= - dependencies: - pinkie-promise "^2.0.0" - -path-exists@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" - integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= - -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= - -path-is-inside@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" - integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= - -path-key@^2.0.0, path-key@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" - integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= - -path-parse@^1.0.5: - version "1.0.6" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" - integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== - -path-to-regexp@0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" - integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= - -path-type@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" - integrity sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE= - dependencies: - graceful-fs "^4.1.2" - pify "^2.0.0" - pinkie-promise "^2.0.0" - -path-type@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73" - integrity sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM= - dependencies: - pify "^2.0.0" - -path-type@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" - integrity sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg== - dependencies: - pify "^3.0.0" - -pbkdf2@^3.0.3: - version "3.0.16" - resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.16.tgz#7404208ec6b01b62d85bf83853a8064f8d9c2a5c" - integrity sha512-y4CXP3thSxqf7c0qmOF+9UeOTrifiVTIM+u7NWlq+PRsHbr7r7dpCmvzrZxa96JJUNi0Y5w9VqG5ZNeCVMoDcA== - dependencies: - create-hash "^1.1.2" - create-hmac "^1.1.4" - ripemd160 "^2.0.1" - safe-buffer "^5.0.1" - sha.js "^2.4.8" - -performance-now@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" - integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= - -pidtree@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.3.0.tgz#f6fada10fccc9f99bf50e90d0b23d72c9ebc2e6b" - integrity sha512-9CT4NFlDcosssyg8KVFltgokyKZIFjoBxw8CTGy+5F38Y1eQWrt8tRayiUOXE+zVKQnYu5BR8JjCtvK3BcnBhg== - -pidusage@^2.0.17: - version "2.0.17" - resolved "https://registry.yarnpkg.com/pidusage/-/pidusage-2.0.17.tgz#6b4a2b4a09026f0e9828f7e5627837e4c0672581" - integrity sha512-N8X5v18rBmlBoArfS83vrnD0gIFyZkXEo7a5pAS2aT0i2OLVymFb2AzVg+v8l/QcXnE1JwZcaXR8daJcoJqtjw== - dependencies: - safe-buffer "^5.1.2" - -pify@^2.0.0, pify@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" - integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= - -pify@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" - integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY= - -pinkie-promise@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" - integrity sha1-ITXW36ejWMBprJsXh3YogihFD/o= - dependencies: - pinkie "^2.0.0" - -pinkie@^2.0.0: - version "2.0.4" - resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" - integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA= - -pkg-dir@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b" - integrity sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s= - dependencies: - find-up "^2.1.0" - -pkg-dir@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3" - integrity sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw== - dependencies: - find-up "^3.0.0" - -pluralize@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777" - integrity sha512-ARhBOdzS3e41FbkW/XWrTEtukqqLoK5+Z/4UeDaLuSW+39JPeFgs4gCGqsrJHVZX0fUrx//4OF0K1CUGwlIFow== - -popper.js@^1.14.1: - version "1.14.4" - resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.14.4.tgz#8eec1d8ff02a5a3a152dd43414a15c7b79fd69b6" - integrity sha1-juwdj/AqWjoVLdQ0FKFce3n9abY= - -portfinder@1.0.17, portfinder@^1.0.9: - version "1.0.17" - resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.17.tgz#a8a1691143e46c4735edefcf4fbcccedad26456a" - integrity sha512-syFcRIRzVI1BoEFOCaAiizwDolh1S1YXSodsVhncbhjzjZQulhczNRbqnUl9N31Q4dKGOXsNDqxC2BWBgSMqeQ== - dependencies: - async "^1.5.2" - debug "^2.2.0" - mkdirp "0.5.x" - -posix-character-classes@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" - integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= - -postcss-import@12.0.0: - version "12.0.0" - resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-12.0.0.tgz#149f96a4ef0b27525c419784be8517ebd17e92c5" - integrity sha512-3KqKRZcaZAvxbY8DVLdd81tG5uKzbUQuiWIvy0o0fzEC42bKacqPYFWbfCQyw6L4LWUaqPz/idvIdbhpgQ32eQ== - dependencies: - postcss "^7.0.1" - postcss-value-parser "^3.2.3" - read-cache "^1.0.0" - resolve "^1.1.7" - -postcss-load-config@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-2.0.0.tgz#f1312ddbf5912cd747177083c5ef7a19d62ee484" - integrity sha512-V5JBLzw406BB8UIfsAWSK2KSwIJ5yoEIVFb4gVkXci0QdKgA24jLmHZ/ghe/GgX0lJ0/D1uUK1ejhzEY94MChQ== - dependencies: - cosmiconfig "^4.0.0" - import-cwd "^2.0.0" - -postcss-loader@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/postcss-loader/-/postcss-loader-3.0.0.tgz#6b97943e47c72d845fa9e03f273773d4e8dd6c2d" - integrity sha512-cLWoDEY5OwHcAjDnkyRQzAXfs2jrKjXpO/HQFcc5b5u/r7aa471wdmChmwfnv7x2u840iat/wi0lQ5nbRgSkUA== - dependencies: - loader-utils "^1.1.0" - postcss "^7.0.0" - postcss-load-config "^2.0.0" - schema-utils "^1.0.0" - -postcss-url@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/postcss-url/-/postcss-url-8.0.0.tgz#7b10059bd12929cdbb1971c60f61a0e5af86b4ca" - integrity sha512-E2cbOQ5aii2zNHh8F6fk1cxls7QVFZjLPSrqvmiza8OuXLzIpErij8BDS5Y3STPfJgpIMNCPEr8JlKQWEoozUw== - dependencies: - mime "^2.3.1" - minimatch "^3.0.4" - mkdirp "^0.5.0" - postcss "^7.0.2" - xxhashjs "^0.2.1" - -postcss-value-parser@^3.2.3: - version "3.3.0" - resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.0.tgz#87f38f9f18f774a4ab4c8a232f5c5ce8872a9d15" - integrity sha1-h/OPnxj3dKSrTIojL1xc6IcqnRU= - -postcss@7.0.5, postcss@^7.0.1: - version "7.0.5" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.5.tgz#70e6443e36a6d520b0fd4e7593fcca3635ee9f55" - integrity sha512-HBNpviAUFCKvEh7NZhw1e8MBPivRszIiUnhrJ+sBFVSYSqubrzwX3KG51mYgcRHX8j/cAgZJedONZcm5jTBdgQ== - dependencies: - chalk "^2.4.1" - source-map "^0.6.1" - supports-color "^5.5.0" - -postcss@^7.0.0, postcss@^7.0.2: - version "7.0.2" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.2.tgz#7b5a109de356804e27f95a960bef0e4d5bc9bb18" - integrity sha512-fmaUY5370keLUTx+CnwRxtGiuFTcNBLQBqr1oE3WZ/euIYmGAo0OAgOhVJ3ByDnVmOR3PK+0V9VebzfjRIUcqw== - dependencies: - chalk "^2.4.1" - source-map "^0.6.1" - supports-color "^5.4.0" - -prelude-ls@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" - integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= - -prepend-http@^1.0.1: - version "1.0.4" - resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" - integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= - -prepend-http@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" - integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= - -preserve@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" - integrity sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks= - -process-nextick-args@~1.0.6: - version "1.0.7" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3" - integrity sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M= - -process-nextick-args@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa" - integrity sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw== - -process@^0.11.10: - version "0.11.10" - resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" - integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= - -promise-inflight@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" - integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= - -promise@^7.1.1: - version "7.3.1" - resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" - integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== - dependencies: - asap "~2.0.3" - -promise@~7.0.1: - version "7.0.4" - resolved "https://registry.yarnpkg.com/promise/-/promise-7.0.4.tgz#363e84a4c36c8356b890fed62c91ce85d02ed539" - integrity sha1-Nj6EpMNsg1a4kP7WLJHOhdAu1Tk= - dependencies: - asap "~2.0.3" - -protobufjs@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-5.0.0.tgz#4223063233ea96ac063ca2b554035204db524fa1" - integrity sha1-QiMGMjPqlqwGPKK1VANSBNtST6E= - dependencies: - ascli "~1" - bytebuffer "~5" - glob "^5.0.10" - yargs "^3.10.0" - -protractor@~5.4.0: - version "5.4.1" - resolved "https://registry.yarnpkg.com/protractor/-/protractor-5.4.1.tgz#011a99e38df7aa45d22455b889ffbb13a6ce0bd9" - integrity sha512-ORey5ewQMYiXQxcQohsqEiKYOg/r5yJoJbt0tuROmmgajdg/CA3gTOZNIFJncUVMAJIk5YFqBBLUjKVmQO6tfA== - dependencies: - "@types/node" "^6.0.46" - "@types/q" "^0.0.32" - "@types/selenium-webdriver" "^3.0.0" - blocking-proxy "^1.0.0" - browserstack "^1.5.1" - chalk "^1.1.3" - glob "^7.0.3" - jasmine "2.8.0" - jasminewd2 "^2.1.0" - optimist "~0.6.0" - q "1.4.1" - saucelabs "^1.5.0" - selenium-webdriver "3.6.0" - source-map-support "~0.4.0" - webdriver-js-extender "2.1.0" - webdriver-manager "^12.0.6" - -proxy-addr@~2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.4.tgz#ecfc733bf22ff8c6f407fa275327b9ab67e48b93" - integrity sha512-5erio2h9jp5CHGwcybmxmVqHmnCBZeewlfJ0pex+UW7Qny7OOZXTtH56TGNyBizkgiOwhJtMKrVzDTeKcySZwA== - dependencies: - forwarded "~0.1.2" - ipaddr.js "1.8.0" - -prr@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476" - integrity sha1-0/wRS6BplaRexok/SEzrHXj19HY= - -pseudomap@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" - integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= - -psl@^1.1.24: - version "1.1.29" - resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.29.tgz#60f580d360170bb722a797cc704411e6da850c67" - integrity sha512-AeUmQ0oLN02flVHXWh9sSJF7mcdFq0ppid/JkErufc3hGIV/AMa8Fo9VgDo/cT2jFdOWoFvHp90qqBH54W+gjQ== - -public-encrypt@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.2.tgz#46eb9107206bf73489f8b85b69d91334c6610994" - integrity sha512-4kJ5Esocg8X3h8YgJsKAuoesBgB7mqH3eowiDzMUPKiRDDE7E/BqqZD1hnTByIaAFiwAw246YEltSq7tdrOH0Q== - dependencies: - bn.js "^4.1.0" - browserify-rsa "^4.0.0" - create-hash "^1.1.0" - parse-asn1 "^5.0.0" - randombytes "^2.0.1" - -pump@^2.0.0, pump@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909" - integrity sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" - -pump@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" - integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" - -pumpify@^1.3.3: - version "1.5.1" - resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce" - integrity sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ== - dependencies: - duplexify "^3.6.0" - inherits "^2.0.3" - pump "^2.0.0" - -punycode@1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" - integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= - -punycode@^1.2.4, punycode@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" - integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= - -punycode@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" - integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== - -q@1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/q/-/q-1.4.1.tgz#55705bcd93c5f3673530c2c2cbc0c2b3addc286e" - integrity sha1-VXBbzZPF82c1MMLCy8DCs63cKG4= - -q@^1.4.1, q@^1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" - integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= - -qjobs@^1.1.4: - version "1.2.0" - resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.2.0.tgz#c45e9c61800bd087ef88d7e256423bdd49e5d071" - integrity sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg== - -qs@6.5.1: - version "6.5.1" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" - integrity sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A== - -qs@6.5.2, qs@~6.5.1, qs@~6.5.2: - version "6.5.2" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" - integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== - -query-string@^5.0.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/query-string/-/query-string-5.1.1.tgz#a78c012b71c17e05f2e3fa2319dd330682efb3cb" - integrity sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw== - dependencies: - decode-uri-component "^0.2.0" - object-assign "^4.1.0" - strict-uri-encode "^1.0.0" - -querystring-es3@^0.2.0: - version "0.2.1" - resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" - integrity sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM= - -querystring@0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" - integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= - -querystringify@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.0.0.tgz#fa3ed6e68eb15159457c89b37bc6472833195755" - integrity sha512-eTPo5t/4bgaMNZxyjWx6N2a6AuE0mq51KWvpc7nU/MAqixcI6v6KrGUKES0HaomdnolQBBXU/++X6/QQ9KL4tw== - -quick-lru@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-1.1.0.tgz#4360b17c61136ad38078397ff11416e186dcfbb8" - integrity sha1-Q2CxfGETatOAeDl/8RQW4Ybc+7g= - -quicktype-core@^5.0.41: - version "5.0.41" - resolved "https://registry.yarnpkg.com/quicktype-core/-/quicktype-core-5.0.41.tgz#83f090b1bddeefcff0581e0d9c754d83410ed453" - integrity sha512-CImdVo4M4TlDs0McPSql9HnaoG5/Z3w1bQIlAhc95qekxBx+jJ2HNIqXzEkIsvx7W7sIPZhOuCug9fvmqNkMKQ== - dependencies: - "@types/urijs" "github:quicktype/types-urijs" - collection-utils "0.0.15" - js-base64 "^2.4.3" - pako "^1.0.6" - pluralize "^7.0.0" - stream-json "0.5.2" - string-to-stream "^1.1.0" - unicode-properties "github:quicktype/unicode-properties#dist" - urijs "^1.19.1" - wordwrap "^1.0.0" - -quote-stream@^1.0.1, quote-stream@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/quote-stream/-/quote-stream-1.0.2.tgz#84963f8c9c26b942e153feeb53aae74652b7e0b2" - integrity sha1-hJY/jJwmuULhU/7rU6rnRlK34LI= - dependencies: - buffer-equal "0.0.1" - minimist "^1.1.3" - through2 "^2.0.0" - -randomatic@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.0.tgz#36f2ca708e9e567f5ed2ec01949026d50aa10116" - integrity sha512-KnGPVE0lo2WoXxIZ7cPR8YBpiol4gsSuOwDSg410oHh80ZMp5EiypNqL2K4Z77vJn6lB5rap7IkAmcUlalcnBQ== - dependencies: - is-number "^4.0.0" - kind-of "^6.0.0" - math-random "^1.0.1" - -randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: - version "2.0.6" - resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.0.6.tgz#d302c522948588848a8d300c932b44c24231da80" - integrity sha512-CIQ5OFxf4Jou6uOKe9t1AOgqpeU5fd70A8NPdHSGeYXqXsPe6peOwI0cUl88RWZ6sP1vPMV3avd/R6cZ5/sP1A== - dependencies: - safe-buffer "^5.1.0" - -randomfill@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/randomfill/-/randomfill-1.0.4.tgz#c92196fc86ab42be983f1bf31778224931d61458" - integrity sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw== - dependencies: - randombytes "^2.0.5" - safe-buffer "^5.1.0" - -range-parser@^1.0.3, range-parser@^1.2.0, range-parser@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" - integrity sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4= - -raw-body@2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz#bcd60c77d3eb93cde0050295c3f379389bc88f89" - integrity sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k= - dependencies: - bytes "3.0.0" - http-errors "1.6.2" - iconv-lite "0.4.19" - unpipe "1.0.0" - -raw-body@2.3.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.3.tgz#1b324ece6b5706e153855bc1148c65bb7f6ea0c3" - integrity sha512-9esiElv1BrZoI3rCDuOuKCBRbuApGGaDPQfjSflGxdy4oyzqghxu6klEkkVIvBje+FF0BX9coEv8KqW6X/7njw== - dependencies: - bytes "3.0.0" - http-errors "1.6.3" - iconv-lite "0.4.23" - unpipe "1.0.0" - -raw-loader@0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-0.5.1.tgz#0c3d0beaed8a01c966d9787bf778281252a979aa" - integrity sha1-DD0L6u2KAclm2Xh793goElKpeao= - -rc@^1.0.1, rc@^1.1.6, rc@^1.2.7: - version "1.2.8" - resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" - integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== - dependencies: - deep-extend "^0.6.0" - ini "~1.3.0" - minimist "^1.2.0" - strip-json-comments "~2.0.1" - -read-cache@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774" - integrity sha1-5mTvMRYRZsl1HNvo28+GtftY93Q= - dependencies: - pify "^2.3.0" - -read-installed@~4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/read-installed/-/read-installed-4.0.3.tgz#ff9b8b67f187d1e4c29b9feb31f6b223acd19067" - integrity sha1-/5uLZ/GH0eTCm5/rMfayI6zRkGc= - dependencies: - debuglog "^1.0.1" - read-package-json "^2.0.0" - readdir-scoped-modules "^1.0.0" - semver "2 || 3 || 4 || 5" - slide "~1.1.3" - util-extend "^1.0.1" - optionalDependencies: - graceful-fs "^4.1.2" - -read-package-json@^2.0.0: - version "2.0.13" - resolved "https://registry.yarnpkg.com/read-package-json/-/read-package-json-2.0.13.tgz#2e82ebd9f613baa6d2ebe3aa72cefe3f68e41f4a" - integrity sha512-/1dZ7TRZvGrYqE0UAfN6qQb5GYBsNcqS1C0tNK601CFOJmtHI7NIGXwetEPU/OtoFHZL3hDxm4rolFFVE9Bnmg== - dependencies: - glob "^7.1.1" - json-parse-better-errors "^1.0.1" - normalize-package-data "^2.0.0" - slash "^1.0.0" - optionalDependencies: - graceful-fs "^4.1.2" - -read-pkg-up@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" - integrity sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI= - dependencies: - find-up "^1.0.0" - read-pkg "^1.0.0" - -read-pkg-up@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be" - integrity sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4= - dependencies: - find-up "^2.0.0" - read-pkg "^2.0.0" - -read-pkg-up@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-3.0.0.tgz#3ed496685dba0f8fe118d0691dc51f4a1ff96f07" - integrity sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc= - dependencies: - find-up "^2.0.0" - read-pkg "^3.0.0" - -read-pkg-up@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-4.0.0.tgz#1b221c6088ba7799601c808f91161c66e58f8978" - integrity sha512-6etQSH7nJGsK0RbG/2TeDzZFa8shjQ1um+SwQQ5cwKy0dhSXdOncEhb1CPpvQG4h7FyOV6EB6YlV0yJvZQNAkA== - dependencies: - find-up "^3.0.0" - read-pkg "^3.0.0" - -read-pkg@^1.0.0, read-pkg@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28" - integrity sha1-9f+qXs0pyzHAR0vKfXVra7KePyg= - dependencies: - load-json-file "^1.0.0" - normalize-package-data "^2.3.2" - path-type "^1.0.0" - -read-pkg@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-2.0.0.tgz#8ef1c0623c6a6db0dc6713c4bfac46332b2368f8" - integrity sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg= - dependencies: - load-json-file "^2.0.0" - normalize-package-data "^2.3.2" - path-type "^2.0.0" - -read-pkg@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389" - integrity sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k= - dependencies: - load-json-file "^4.0.0" - normalize-package-data "^2.3.2" - path-type "^3.0.0" - -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.6, readable-stream@^2.1.0, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.2.9, readable-stream@^2.3.0, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.3: - version "2.3.6" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" - integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - -readable-stream@~2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.0.6.tgz#8f90341e68a53ccc928788dacfcd11b36eb9b78e" - integrity sha1-j5A0HmilPMySh4jaz80Rs265t44= - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.1" - isarray "~1.0.0" - process-nextick-args "~1.0.6" - string_decoder "~0.10.x" - util-deprecate "~1.0.1" - -readdir-scoped-modules@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.0.2.tgz#9fafa37d286be5d92cbaebdee030dc9b5f406747" - integrity sha1-n6+jfShr5dksuuve4DDcm19AZ0c= - dependencies: - debuglog "^1.0.1" - dezalgo "^1.0.0" - graceful-fs "^4.1.2" - once "^1.3.0" - -readdirp@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.1.0.tgz#4ed0ad060df3073300c48440373f72d1cc642d78" - integrity sha1-TtCtBg3zBzMAxIRANz9y0cxkLXg= - dependencies: - graceful-fs "^4.1.2" - minimatch "^3.0.2" - readable-stream "^2.0.2" - set-immediate-shim "^1.0.1" - -rechoir@^0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" - integrity sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q= - dependencies: - resolve "^1.1.6" - -redent@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde" - integrity sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94= - dependencies: - indent-string "^2.1.0" - strip-indent "^1.0.1" - -redent@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/redent/-/redent-2.0.0.tgz#c1b2007b42d57eb1389079b3c8333639d5e1ccaa" - integrity sha1-wbIAe0LVfrE4kHmzyDM2OdXhzKo= - dependencies: - indent-string "^3.0.0" - strip-indent "^2.0.0" - -reflect-metadata@^0.1.2: - version "0.1.12" - resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.12.tgz#311bf0c6b63cd782f228a81abe146a2bfa9c56f2" - integrity sha512-n+IyV+nGz3+0q3/Yf1ra12KpCyi001bi4XFxSjbiWWjfqb52iTTtpGXmCCAOWWIAn9KEuFZKGqBERHmrtScZ3A== - -regenerate@^1.2.1: - version "1.4.0" - resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11" - integrity sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg== - -regenerator-runtime@^0.11.0: - version "0.11.1" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" - integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== - -regex-cache@^0.4.2: - version "0.4.4" - resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd" - integrity sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ== - dependencies: - is-equal-shallow "^0.1.3" - -regex-not@^1.0.0, regex-not@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" - integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A== - dependencies: - extend-shallow "^3.0.2" - safe-regex "^1.1.0" - -regexpu-core@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-1.0.0.tgz#86a763f58ee4d7c2f6b102e4764050de7ed90c6b" - integrity sha1-hqdj9Y7k18L2sQLkdkBQ3n7ZDGs= - dependencies: - regenerate "^1.2.1" - regjsgen "^0.2.0" - regjsparser "^0.1.4" - -registry-auth-token@^3.0.1: - version "3.3.2" - resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.3.2.tgz#851fd49038eecb586911115af845260eec983f20" - integrity sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ== - dependencies: - rc "^1.1.6" - safe-buffer "^5.0.1" - -registry-url@^3.0.3: - version "3.1.0" - resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942" - integrity sha1-PU74cPc93h138M+aOBQyRE4XSUI= - dependencies: - rc "^1.0.1" - -regjsgen@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.2.0.tgz#6c016adeac554f75823fe37ac05b92d5a4edb1f7" - integrity sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc= - -regjsparser@^0.1.4: - version "0.1.5" - resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.1.5.tgz#7ee8f84dc6fa792d3fd0ae228d24bd949ead205c" - integrity sha1-fuj4Tcb6eS0/0K4ijSS9lJ6tIFw= - dependencies: - jsesc "~0.5.0" - -remove-trailing-separator@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" - integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= - -repeat-element@^1.1.2: - version "1.1.3" - resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" - integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g== - -repeat-string@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-0.2.2.tgz#c7a8d3236068362059a7e4651fc6884e8b1fb4ae" - integrity sha1-x6jTI2BoNiBZp+RlH8aITosftK4= - -repeat-string@^1.5.2, repeat-string@^1.6.1: - version "1.6.1" - resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" - integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= - -repeating@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda" - integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo= - dependencies: - is-finite "^1.0.0" - -request@2.87.0: - version "2.87.0" - resolved "https://registry.yarnpkg.com/request/-/request-2.87.0.tgz#32f00235cd08d482b4d0d68db93a829c0ed5756e" - integrity sha512-fcogkm7Az5bsS6Sl0sibkbhcKsnyon/jV1kF3ajGmF0c8HrttdKTPRT9hieOaQHA5HEq6r8OyWOo/o781C1tNw== - dependencies: - aws-sign2 "~0.7.0" - aws4 "^1.6.0" - caseless "~0.12.0" - combined-stream "~1.0.5" - extend "~3.0.1" - forever-agent "~0.6.1" - form-data "~2.3.1" - har-validator "~5.0.3" - http-signature "~1.2.0" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.17" - oauth-sign "~0.8.2" - performance-now "^2.1.0" - qs "~6.5.1" - safe-buffer "^5.1.1" - tough-cookie "~2.3.3" - tunnel-agent "^0.6.0" - uuid "^3.1.0" - -request@^2.74.0, request@^2.83.0, request@^2.87.0: - version "2.88.0" - resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" - integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg== - dependencies: - aws-sign2 "~0.7.0" - aws4 "^1.8.0" - caseless "~0.12.0" - combined-stream "~1.0.6" - extend "~3.0.2" - forever-agent "~0.6.1" - form-data "~2.3.2" - har-validator "~5.1.0" - http-signature "~1.2.0" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.19" - oauth-sign "~0.9.0" - performance-now "^2.1.0" - qs "~6.5.2" - safe-buffer "^5.1.2" - tough-cookie "~2.4.3" - tunnel-agent "^0.6.0" - uuid "^3.3.2" - -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= - -require-from-string@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" - integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== - -require-main-filename@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" - integrity sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE= - -requires-port@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" - integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= - -resolve-cwd@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" - integrity sha1-AKn3OHVW4nA46uIyyqNypqWbZlo= - dependencies: - resolve-from "^3.0.0" - -resolve-from@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" - integrity sha1-six699nWiBvItuZTM17rywoYh0g= - -resolve-url@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" - integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= - -resolve@1.1.x, resolve@~1.1.6: - version "1.1.7" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" - integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= - -resolve@^1.1.5, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2, resolve@^1.5.0: - version "1.8.1" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26" - integrity sha512-AicPrAC7Qu1JxPCZ9ZgCZlY35QgFnNqc+0LtbRNxnVw4TXvjQ72wnuL9JQcEBgXkI9JM8MsT9kaQoHcpCRJOYA== - dependencies: - path-parse "^1.0.5" - -responselike@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" - integrity sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec= - dependencies: - lowercase-keys "^1.0.0" - -restore-cursor@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" - integrity sha1-n37ih/gv0ybU/RYpI9YhKe7g368= - dependencies: - onetime "^2.0.0" - signal-exit "^3.0.2" - -ret@~0.1.10: - version "0.1.15" - resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" - integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== - -retry@^0.10.0: - version "0.10.1" - resolved "https://registry.yarnpkg.com/retry/-/retry-0.10.1.tgz#e76388d217992c252750241d3d3956fed98d8ff4" - integrity sha1-52OI0heZLCUnUCQdPTlW/tmNj/Q= - -rfdc@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.1.2.tgz#e6e72d74f5dc39de8f538f65e00c36c18018e349" - integrity sha512-92ktAgvZhBzYTIK0Mja9uen5q5J3NRVMoDkJL2VMwq6SXjVCgqvQeVP2XAaUY6HT+XpQYeLSjb3UoitBryKmdA== - -rimraf@2, rimraf@^2.2.8, rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6.0, rimraf@^2.6.1, rimraf@^2.6.2: - version "2.6.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36" - integrity sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w== - dependencies: - glob "^7.0.5" - -rimraf@~2.2.6: - version "2.2.8" - resolved "http://registry.npmjs.org/rimraf/-/rimraf-2.2.8.tgz#e439be2aaee327321952730f99a8929e4fc50582" - integrity sha1-5Dm+Kq7jJzIZUnMPmaiSnk/FBYI= - -ripemd160@^2.0.0, ripemd160@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" - integrity sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA== - dependencies: - hash-base "^3.0.0" - inherits "^2.0.1" - -rollup-plugin-commonjs@^9.1.3: - version "9.1.6" - resolved "https://registry.yarnpkg.com/rollup-plugin-commonjs/-/rollup-plugin-commonjs-9.1.6.tgz#ad553813c922b71467152794b98f2fd0f195b8a5" - integrity sha512-J7GOJm9uzEeLqkVxYSgjyoieh34hATWpa9G2M1ilGzWOLYGfQx5IDQ9ewG8QUj/Z2dzgV+d0/AyloAzElkABAA== - dependencies: - estree-walker "^0.5.1" - magic-string "^0.22.4" - resolve "^1.5.0" - rollup-pluginutils "^2.0.1" - -rollup-plugin-node-resolve@^3.0.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-3.4.0.tgz#908585eda12e393caac7498715a01e08606abc89" - integrity sha512-PJcd85dxfSBWih84ozRtBkB731OjXk0KnzN0oGp7WOWcarAFkVa71cV5hTJg2qpVsV2U8EUwrzHP3tvy9vS3qg== - dependencies: - builtin-modules "^2.0.0" - is-module "^1.0.0" - resolve "^1.1.6" - -rollup-plugin-sourcemaps@^0.4.2: - version "0.4.2" - resolved "https://registry.yarnpkg.com/rollup-plugin-sourcemaps/-/rollup-plugin-sourcemaps-0.4.2.tgz#62125aa94087aadf7b83ef4dfaf629b473135e87" - integrity sha1-YhJaqUCHqt97g+9N+vYptHMTXoc= - dependencies: - rollup-pluginutils "^2.0.1" - source-map-resolve "^0.5.0" - -rollup-pluginutils@^2.0.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.3.1.tgz#760d185ccc237dedc12d7ae48c6bcd127b4892d0" - integrity sha512-JZS8aJMHEHhqmY2QVPMXwKP6lsD1ShkrcGYjhAIvqKKdXQyPHw/9NF0tl3On/xOJ4ACkxfeG7AF+chfCN1NpBg== - dependencies: - estree-walker "^0.5.2" - micromatch "^2.3.11" - -rollup@^0.66.0: - version "0.66.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-0.66.0.tgz#697acc008f4b613695b17222c7626affddf4a506" - integrity sha512-WOkVBSdod3phCSAri5Q579JufeAzJTi43L4oAHzFtjRWJTv2LIszYCU3K9TLMEsVksMY93EwgWCb+hpKRhQwGg== - dependencies: - "@types/estree" "0.0.39" - "@types/node" "*" - -run-async@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0" - integrity sha1-A3GrSuC91yDUFm19/aZP96RFpsA= - dependencies: - is-promise "^2.1.0" - -run-queue@^1.0.0, run-queue@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/run-queue/-/run-queue-1.0.3.tgz#e848396f057d223f24386924618e25694161ec47" - integrity sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec= - dependencies: - aproba "^1.1.1" - -rxjs@6.3.3, rxjs@^6.0.0, rxjs@^6.1.0, rxjs@~6.3.0: - version "6.3.3" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.3.3.tgz#3c6a7fa420e844a81390fb1158a9ec614f4bad55" - integrity sha512-JTWmoY9tWCs7zvIk/CvRjhjGaOd+OVBM987mxFo+OW66cGpdKjZcpmc74ES1sB//7Kl/PAe8+wEakuhG4pcgOw== - dependencies: - tslib "^1.9.0" - -safe-buffer@5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" - integrity sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg== - -safe-buffer@5.1.2, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: - version "5.1.2" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" - integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== - -safe-regex@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" - integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4= - dependencies: - ret "~0.1.10" - -"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -sass-graph@^2.2.4: - version "2.2.4" - resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.4.tgz#13fbd63cd1caf0908b9fd93476ad43a51d1e0b49" - integrity sha1-E/vWPNHK8JCLn9k0dq1DpR0eC0k= - dependencies: - glob "^7.0.0" - lodash "^4.0.0" - scss-tokenizer "^0.2.3" - yargs "^7.0.0" - -sass-loader@7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-7.1.0.tgz#16fd5138cb8b424bf8a759528a1972d72aad069d" - integrity sha512-+G+BKGglmZM2GUSfT9TLuEp6tzehHPjAMoRRItOojWIqIGPloVCMhNIQuG639eJ+y033PaGTSjLaTHts8Kw79w== - dependencies: - clone-deep "^2.0.1" - loader-utils "^1.0.1" - lodash.tail "^4.1.1" - neo-async "^2.5.0" - pify "^3.0.0" - semver "^5.5.0" - -saucelabs@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/saucelabs/-/saucelabs-1.5.0.tgz#9405a73c360d449b232839919a86c396d379fd9d" - integrity sha512-jlX3FGdWvYf4Q3LFfFWS1QvPg3IGCGWxIc8QBFdPTbpTJnt/v17FHXYVAn7C8sHf1yUXo2c7yIM0isDryfYtHQ== - dependencies: - https-proxy-agent "^2.2.1" - -sax@0.5.x: - version "0.5.8" - resolved "http://registry.npmjs.org/sax/-/sax-0.5.8.tgz#d472db228eb331c2506b0e8c15524adb939d12c1" - integrity sha1-1HLbIo6zMcJQaw6MFVJK25OdEsE= - -sax@>=0.6.0, sax@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" - integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== - -schema-utils@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.3.0.tgz#f5877222ce3e931edae039f17eb3716e7137f8cf" - integrity sha1-9YdyIs4+kx7a4DnxfrNxbnE3+M8= - dependencies: - ajv "^5.0.0" - -schema-utils@^0.4.4, schema-utils@^0.4.5: - version "0.4.7" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.4.7.tgz#ba74f597d2be2ea880131746ee17d0a093c68187" - integrity sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ== - dependencies: - ajv "^6.1.0" - ajv-keywords "^3.1.0" - -schema-utils@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770" - integrity sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g== - dependencies: - ajv "^6.1.0" - ajv-errors "^1.0.0" - ajv-keywords "^3.1.0" - -scss-tokenizer@^0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz#8eb06db9a9723333824d3f5530641149847ce5d1" - integrity sha1-jrBtualyMzOCTT9VMGQRSYR85dE= - dependencies: - js-base64 "^2.1.8" - source-map "^0.4.2" - -seedrandom@^2.4.4: - version "2.4.4" - resolved "https://registry.yarnpkg.com/seedrandom/-/seedrandom-2.4.4.tgz#b25ea98632c73e45f58b77cfaa931678df01f9ba" - integrity sha512-9A+PDmgm+2du77B5i0Ip2cxOqqHjgNxnBgglxLcX78A2D6c2rTo61z4jnVABpF4cKeDMDG+cmXXvdnqse2VqMA== - -select-hose@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" - integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= - -selenium-webdriver@3.6.0, selenium-webdriver@^3.0.1: - version "3.6.0" - resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-3.6.0.tgz#2ba87a1662c020b8988c981ae62cb2a01298eafc" - integrity sha512-WH7Aldse+2P5bbFBO4Gle/nuQOdVwpHMTL6raL3uuBj/vPG07k6uzt3aiahu352ONBr5xXh0hDlM3LhtXPOC4Q== - dependencies: - jszip "^3.1.3" - rimraf "^2.5.4" - tmp "0.0.30" - xml2js "^0.4.17" - -selfsigned@^1.9.1: - version "1.10.3" - resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-1.10.3.tgz#d628ecf9e3735f84e8bafba936b3cf85bea43823" - integrity sha512-vmZenZ+8Al3NLHkWnhBQ0x6BkML1eCP2xEi3JE+f3D9wW9fipD9NNJHYtE9XJM4TsPaHGZJIamrSI6MTg1dU2Q== - dependencies: - node-forge "0.7.5" - -semver-diff@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36" - integrity sha1-S7uEN8jTfksM8aaP1ybsbWRdbTY= - dependencies: - semver "^5.0.3" - -semver-dsl@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/semver-dsl/-/semver-dsl-1.0.1.tgz#d3678de5555e8a61f629eed025366ae5f27340a0" - integrity sha1-02eN5VVeimH2Ke7QJTZq5fJzQKA= - dependencies: - semver "^5.3.0" - -semver-intersect@1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/semver-intersect/-/semver-intersect-1.4.0.tgz#bdd9c06bedcdd2fedb8cd352c3c43ee8c61321f3" - integrity sha512-d8fvGg5ycKAq0+I6nfWeCx6ffaWJCsBYU0H2Rq56+/zFePYfT8mXkB3tWBSjR5BerkHNZ5eTPIk1/LBYas35xQ== - dependencies: - semver "^5.0.0" - -"semver@2 >=2.2.1 || 3.x || 4 || 5", "semver@2 || 3 || 4 || 5", semver@5.5.1, semver@^5.0.0, semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@^5.5.0: - version "5.5.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.1.tgz#7dfdd8814bdb7cabc7be0fb1d734cfb66c940477" - integrity sha512-PqpAxfrEhlSUWge8dwIp4tZnQ25DIOthpiaHNIthsjEFQD6EvqUKUDM7L8O2rShkFccYo1VjJR0coWfNkCubRw== - -semver@~5.3.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" - integrity sha1-myzl094C0XxgEq0yaqa00M9U+U8= - -send@0.16.2: - version "0.16.2" - resolved "https://registry.yarnpkg.com/send/-/send-0.16.2.tgz#6ecca1e0f8c156d141597559848df64730a6bbc1" - integrity sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw== - dependencies: - debug "2.6.9" - depd "~1.1.2" - destroy "~1.0.4" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - fresh "0.5.2" - http-errors "~1.6.2" - mime "1.4.1" - ms "2.0.0" - on-finished "~2.3.0" - range-parser "~1.2.0" - statuses "~1.4.0" - -serialize-javascript@^1.4.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-1.5.0.tgz#1aa336162c88a890ddad5384baebc93a655161fe" - integrity sha512-Ga8c8NjAAp46Br4+0oZ2WxJCwIzwP60Gq1YPgU+39PiTVxyed/iKE/zyZI6+UlVYH5Q4PaQdHhcegIFPZTUfoQ== - -serve-index@^1.7.2: - version "1.9.1" - resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" - integrity sha1-03aNabHn2C5c4FD/9bRTvqEqkjk= - dependencies: - accepts "~1.3.4" - batch "0.6.1" - debug "2.6.9" - escape-html "~1.0.3" - http-errors "~1.6.2" - mime-types "~2.1.17" - parseurl "~1.3.2" - -serve-static@1.13.2: - version "1.13.2" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.13.2.tgz#095e8472fd5b46237db50ce486a43f4b86c6cec1" - integrity sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw== - dependencies: - encodeurl "~1.0.2" - escape-html "~1.0.3" - parseurl "~1.3.2" - send "0.16.2" - -set-blocking@^2.0.0, set-blocking@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= - -set-immediate-shim@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61" - integrity sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E= - -set-value@^0.4.3: - version "0.4.3" - resolved "https://registry.yarnpkg.com/set-value/-/set-value-0.4.3.tgz#7db08f9d3d22dc7f78e53af3c3bf4666ecdfccf1" - integrity sha1-fbCPnT0i3H945Trzw79GZuzfzPE= - dependencies: - extend-shallow "^2.0.1" - is-extendable "^0.1.1" - is-plain-object "^2.0.1" - to-object-path "^0.3.0" - -set-value@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.0.tgz#71ae4a88f0feefbbf52d1ea604f3fb315ebb6274" - integrity sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg== - dependencies: - extend-shallow "^2.0.1" - is-extendable "^0.1.1" - is-plain-object "^2.0.3" - split-string "^3.0.1" - -setimmediate@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" - integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= - -setprototypeof@1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.3.tgz#66567e37043eeb4f04d91bd658c0cbefb55b8e04" - integrity sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ= - -setprototypeof@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" - integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ== - -sha.js@^2.4.0, sha.js@^2.4.8: - version "2.4.11" - resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" - integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== - dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" - -shallow-clone@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-1.0.0.tgz#4480cd06e882ef68b2ad88a3ea54832e2c48b571" - integrity sha512-oeXreoKR/SyNJtRJMAKPDSvd28OqEwG4eR/xc856cRGBII7gX9lvAqDxusPm0846z/w/hWYjI1NpKwJ00NHzRA== - dependencies: - is-extendable "^0.1.1" - kind-of "^5.0.0" - mixin-object "^2.0.1" - -shallow-copy@~0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/shallow-copy/-/shallow-copy-0.0.1.tgz#415f42702d73d810330292cc5ee86eae1a11a170" - integrity sha1-QV9CcC1z2BAzApLMXuhurhoRoXA= - -shebang-command@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" - integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= - dependencies: - shebang-regex "^1.0.0" - -shebang-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" - integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= - -shelljs@^0.8.1: - version "0.8.2" - resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.2.tgz#345b7df7763f4c2340d584abb532c5f752ca9e35" - integrity sha512-pRXeNrCA2Wd9itwhvLp5LZQvPJ0wU6bcjaTMywHHGX5XWhVN2nzSu7WV0q+oUY7mGK3mgSkDDzP3MgjqdyIgbQ== - dependencies: - glob "^7.0.0" - interpret "^1.0.0" - rechoir "^0.6.2" - -signal-exit@^3.0.0, signal-exit@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" - integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= - -simple-swizzle@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" - integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo= - dependencies: - is-arrayish "^0.3.1" - -slash@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" - integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU= - -slide@^1.1.3, slide@~1.1.3: - version "1.1.6" - resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707" - integrity sha1-VusCfWW00tzmyy4tMsTUr8nh1wc= - -snapdragon-node@^2.0.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" - integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw== - dependencies: - define-property "^1.0.0" - isobject "^3.0.0" - snapdragon-util "^3.0.1" - -snapdragon-util@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" - integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ== - dependencies: - kind-of "^3.2.0" - -snapdragon@^0.8.1: - version "0.8.2" - resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" - integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg== - dependencies: - base "^0.11.1" - debug "^2.2.0" - define-property "^0.2.5" - extend-shallow "^2.0.1" - map-cache "^0.2.2" - source-map "^0.5.6" - source-map-resolve "^0.5.0" - use "^3.1.0" - -socket.io-adapter@~1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.1.tgz#2a805e8a14d6372124dd9159ad4502f8cb07f06b" - integrity sha1-KoBeihTWNyEk3ZFZrUUC+MsH8Gs= - -socket.io-client@2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.1.1.tgz#dcb38103436ab4578ddb026638ae2f21b623671f" - integrity sha512-jxnFyhAuFxYfjqIgduQlhzqTcOEQSn+OHKVfAxWaNWa7ecP7xSNk2Dx/3UEsDcY7NcFafxvNvKPmmO7HTwTxGQ== - dependencies: - backo2 "1.0.2" - base64-arraybuffer "0.1.5" - component-bind "1.0.0" - component-emitter "1.2.1" - debug "~3.1.0" - engine.io-client "~3.2.0" - has-binary2 "~1.0.2" - has-cors "1.1.0" - indexof "0.0.1" - object-component "0.0.3" - parseqs "0.0.5" - parseuri "0.0.5" - socket.io-parser "~3.2.0" - to-array "0.1.4" - -socket.io-parser@~3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.2.0.tgz#e7c6228b6aa1f814e6148aea325b51aa9499e077" - integrity sha512-FYiBx7rc/KORMJlgsXysflWx/RIvtqZbyGLlHZvjfmPTPeuD/I8MaW7cfFrj5tRltICJdgwflhfZ3NVVbVLFQA== - dependencies: - component-emitter "1.2.1" - debug "~3.1.0" - isarray "2.0.1" - -socket.io@2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.1.1.tgz#a069c5feabee3e6b214a75b40ce0652e1cfb9980" - integrity sha512-rORqq9c+7W0DAK3cleWNSyfv/qKXV99hV4tZe+gGLfBECw3XEhBy7x85F3wypA9688LKjtwO9pX9L33/xQI8yA== - dependencies: - debug "~3.1.0" - engine.io "~3.2.0" - has-binary2 "~1.0.2" - socket.io-adapter "~1.1.0" - socket.io-client "2.1.1" - socket.io-parser "~3.2.0" - -sockjs-client@1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.1.5.tgz#1bb7c0f7222c40f42adf14f4442cbd1269771a83" - integrity sha1-G7fA9yIsQPQq3xT0RCy9Eml3GoM= - dependencies: - debug "^2.6.6" - eventsource "0.1.6" - faye-websocket "~0.11.0" - inherits "^2.0.1" - json3 "^3.3.2" - url-parse "^1.1.8" - -sockjs-client@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.3.0.tgz#12fc9d6cb663da5739d3dc5fb6e8687da95cb177" - integrity sha512-R9jxEzhnnrdxLCNln0xg5uGHqMnkhPSTzUZH2eXcR03S/On9Yvoq2wyUZILRUhZCNVu2PmwWVoyuiPz8th8zbg== - dependencies: - debug "^3.2.5" - eventsource "^1.0.7" - faye-websocket "~0.11.1" - inherits "^2.0.3" - json3 "^3.3.2" - url-parse "^1.4.3" - -sockjs@0.3.19: - version "0.3.19" - resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.19.tgz#d976bbe800af7bd20ae08598d582393508993c0d" - integrity sha512-V48klKZl8T6MzatbLlzzRNhMepEys9Y4oGFpypBFFn1gLI/QQ9HtLLyWJNbPlwGLelOVOEijUbTTJeLLI59jLw== - dependencies: - faye-websocket "^0.10.0" - uuid "^3.0.1" - -sort-keys@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-2.0.0.tgz#658535584861ec97d730d6cf41822e1f56684128" - integrity sha1-ZYU1WEhh7JfXMNbPQYIuH1ZoQSg= - dependencies: - is-plain-obj "^1.0.0" - -source-list-map@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.0.tgz#aaa47403f7b245a92fbc97ea08f250d6087ed085" - integrity sha512-I2UmuJSRr/T8jisiROLU3A3ltr+swpniSmNPI4Ml3ZCX6tVnDsuZzK7F2hl5jTqbZBWCEKlj5HRQiPExXLgE8A== - -source-list-map@~0.1.7: - version "0.1.8" - resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-0.1.8.tgz#c550b2ab5427f6b3f21f5afead88c4f5587b2106" - integrity sha1-xVCyq1Qn9rPyH1r+rYjE9Vh7IQY= - -source-map-loader@0.2.4: - version "0.2.4" - resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-0.2.4.tgz#c18b0dc6e23bf66f6792437557c569a11e072271" - integrity sha512-OU6UJUty+i2JDpTItnizPrlpOIBLmQbWMuBg9q5bVtnHACqw1tn9nNwqJLbv0/00JjnJb/Ee5g5WS5vrRv7zIQ== - dependencies: - async "^2.5.0" - loader-utils "^1.1.0" - -source-map-resolve@^0.5.0: - version "0.5.2" - resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.2.tgz#72e2cc34095543e43b2c62b2c4c10d4a9054f259" - integrity sha512-MjqsvNwyz1s0k81Goz/9vRBe9SZdB09Bdw+/zYyO+3CuPk6fouTaxscHkgtE8jKvf01kVfl8riHzERQ/kefaSA== - dependencies: - atob "^2.1.1" - decode-uri-component "^0.2.0" - resolve-url "^0.2.1" - source-map-url "^0.4.0" - urix "^0.1.0" - -source-map-support@0.5.9, source-map-support@^0.5.0, source-map-support@^0.5.3, source-map-support@^0.5.5, source-map-support@~0.5.6: - version "0.5.9" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.9.tgz#41bc953b2534267ea2d605bccfa7bfa3111ced5f" - integrity sha512-gR6Rw4MvUlYy83vP0vxoVNzM6t8MUXqNuRsuBmBHQDu1Fh6X015FrLdgoDKcNdkwGubozq0P4N0Q37UyFVr1EA== - dependencies: - buffer-from "^1.0.0" - source-map "^0.6.0" - -source-map-support@~0.4.0: - version "0.4.18" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.18.tgz#0286a6de8be42641338594e97ccea75f0a2c585f" - integrity sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA== - dependencies: - source-map "^0.5.6" - -source-map-url@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" - integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= - -source-map@0.1.x: - version "0.1.43" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.43.tgz#c24bc146ca517c1471f5dacbe2571b2b7f9e3346" - integrity sha1-wkvBRspRfBRx9drL4lcbK3+eM0Y= - dependencies: - amdefine ">=0.0.4" - -source-map@0.5.6: - version "0.5.6" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" - integrity sha1-dc449SvwczxafwwRjYEzSiu19BI= - -source-map@0.7.3: - version "0.7.3" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" - integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== - -source-map@^0.4.2, source-map@~0.4.1: - version "0.4.4" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b" - integrity sha1-66T12pwNyZneaAMti092FzZSA2s= - dependencies: - amdefine ">=0.0.4" - -source-map@^0.5.0, source-map@^0.5.6, source-map@^0.5.7: - version "0.5.7" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" - integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= - -source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - -source-map@~0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.2.0.tgz#dab73fbcfc2ba819b4de03bd6f6eaa48164b3f9d" - integrity sha1-2rc/vPwrqBm03gO9b26qSBZLP50= - dependencies: - amdefine ">=0.0.4" - -sourcemap-codec@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.1.tgz#c8fd92d91889e902a07aee392bdd2c5863958ba2" - integrity sha512-hX1eNBNuilj8yfFnECh0DzLgwKpBLMIvmhgEhixXNui8lMLBInTI8Kyxt++RwJnMNu7cAUo635L2+N1TxMJCzA== - -spdx-compare@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/spdx-compare/-/spdx-compare-1.0.0.tgz#2c55f117362078d7409e6d7b08ce70a857cd3ed7" - integrity sha512-C1mDZOX0hnu0ep9dfmuoi03+eOdDoz2yvK79RxbcrVEG1NO1Ph35yW102DHWKN4pk80nwCgeMmSY5L25VE4D9A== - dependencies: - array-find-index "^1.0.2" - spdx-expression-parse "^3.0.0" - spdx-ranges "^2.0.0" - -spdx-correct@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.0.0.tgz#05a5b4d7153a195bc92c3c425b69f3b2a9524c82" - integrity sha512-N19o9z5cEyc8yQQPukRCZ9EUmb4HUpnrmaL/fxS2pBo2jbfcFRVuFZ/oFC+vZz0MNNk0h80iMn5/S6qGZOL5+g== - dependencies: - spdx-expression-parse "^3.0.0" - spdx-license-ids "^3.0.0" - -spdx-exceptions@^1.0.0: - version "1.0.5" - resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-1.0.5.tgz#9d21ac4da4bdb71d060fb74e5a67531d032cbba6" - integrity sha1-nSGsTaS9tx0GD7dOWmdTHQMsu6Y= - -spdx-exceptions@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.1.0.tgz#2c7ae61056c714a5b9b9b2b2af7d311ef5c78fe9" - integrity sha512-4K1NsmrlCU1JJgUrtgEeTVyfx8VaYea9J9LvARxhbHtVtohPs/gFGG5yy49beySjlIMhhXZ4QqujIZEfS4l6Cg== - -spdx-expression-parse@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0" - integrity sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg== - dependencies: - spdx-exceptions "^2.1.0" - spdx-license-ids "^3.0.0" - -spdx-license-ids@^1.0.0: - version "1.2.2" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-1.2.2.tgz#c9df7a3424594ade6bd11900d596696dc06bac57" - integrity sha1-yd96NCRZSt5r0RkA1ZZpbcBrrFc= - -spdx-license-ids@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.1.tgz#e2a303236cac54b04031fa7a5a79c7e701df852f" - integrity sha512-TfOfPcYGBB5sDuPn3deByxPhmfegAhpDYKSOXZQN81Oyrrif8ZCodOLzK3AesELnCx03kikhyDwh0pfvvQvF8w== - -spdx-ranges@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/spdx-ranges/-/spdx-ranges-2.0.0.tgz#257686798e5edb41d45c1aba3d3f1bb47af8d5ec" - integrity sha512-AUUXLfqkwD7GlzZkXv8ePPCpPjeVWI9xJCfysL8re/uKb6H10umMnC7bFRsHmLJan4fslUtekAgpHlSgLc/7mA== - -spdx-satisfies@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/spdx-satisfies/-/spdx-satisfies-4.0.0.tgz#ebc79eec88b68ac75618e2e5ee94fbc347587552" - integrity sha512-OcARj6U1OuVv98SVrRqgrR30sVocONtoPpnX8Xz4vXNrFVedqtbgkA+0KmQoXIQ2xjfltPPRVIMeNzKEFLWWKQ== - dependencies: - spdx-compare "^1.0.0" - spdx-expression-parse "^3.0.0" - spdx-ranges "^2.0.0" - -spdx@^0.5.1: - version "0.5.2" - resolved "https://registry.yarnpkg.com/spdx/-/spdx-0.5.2.tgz#76a428b9b97e7904ef83e62a4af0d06fdb50c265" - integrity sha512-WQbfCQT2uKLsDllnO9ItpcGUiiF1O/ZvBGCyqFZRg122HgiZubpwpZiM7BkmH19HC3XR3Z+DFMGJNzXSPebG8A== - dependencies: - spdx-exceptions "^1.0.0" - spdx-license-ids "^1.0.0" - -spdy-transport@^2.0.18: - version "2.1.0" - resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-2.1.0.tgz#4bbb15aaffed0beefdd56ad61dbdc8ba3e2cb7a1" - integrity sha512-bpUeGpZcmZ692rrTiqf9/2EUakI6/kXX1Rpe0ib/DyOzbiexVfXkw6GnvI9hVGvIwVaUhkaBojjCZwLNRGQg1g== - dependencies: - debug "^2.6.8" - detect-node "^2.0.3" - hpack.js "^2.1.6" - obuf "^1.1.1" - readable-stream "^2.2.9" - safe-buffer "^5.0.1" - wbuf "^1.7.2" - -spdy@^3.4.1: - version "3.4.7" - resolved "https://registry.yarnpkg.com/spdy/-/spdy-3.4.7.tgz#42ff41ece5cc0f99a3a6c28aabb73f5c3b03acbc" - integrity sha1-Qv9B7OXMD5mjpsKKq7c/XDsDrLw= - dependencies: - debug "^2.6.8" - handle-thing "^1.2.5" - http-deceiver "^1.2.7" - safe-buffer "^5.0.1" - select-hose "^2.0.0" - spdy-transport "^2.0.18" - -speed-measure-webpack-plugin@1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/speed-measure-webpack-plugin/-/speed-measure-webpack-plugin-1.2.3.tgz#de170b5cefbfa1c039d95e639edd3ad50cfc7c48" - integrity sha512-p+taQ69VkRUXYMoZOx2nxV/Tz8tt79ahctoZJyJDHWP7fEYvwFNf5Pd73k5kZ6auu0pTsPNLEUwWpM8mCk85Zw== - dependencies: - chalk "^2.0.1" - -split-string@^3.0.1, split-string@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" - integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw== - dependencies: - extend-shallow "^3.0.0" - -split2@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/split2/-/split2-2.2.0.tgz#186b2575bcf83e85b7d18465756238ee4ee42493" - integrity sha512-RAb22TG39LhI31MbreBgIuKiIKhVsawfTgEGqKHTK87aG+ul/PB8Sqoi3I7kVdRWiCfrKxK3uo4/YUkpNvhPbw== - dependencies: - through2 "^2.0.2" - -split@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/split/-/split-1.0.1.tgz#605bd9be303aa59fb35f9229fbea0ddec9ea07d9" - integrity sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg== - dependencies: - through "2" - -sprintf-js@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.1.tgz#36be78320afe5801f6cea3ee78b6e5aab940ea0c" - integrity sha1-Nr54Mgr+WAH2zqPueLblqrlA6gw= - -sprintf-js@~1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" - integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= - -sshpk@^1.7.0: - version "1.14.2" - resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.14.2.tgz#c6fc61648a3d9c4e764fd3fcdf4ea105e492ba98" - integrity sha1-xvxhZIo9nE52T9P8306hBeSSupg= - dependencies: - asn1 "~0.2.3" - assert-plus "^1.0.0" - dashdash "^1.12.0" - getpass "^0.1.1" - safer-buffer "^2.0.2" - optionalDependencies: - bcrypt-pbkdf "^1.0.0" - ecc-jsbn "~0.1.1" - jsbn "~0.1.0" - tweetnacl "~0.14.0" - -ssri@^5.2.4: - version "5.3.0" - resolved "https://registry.yarnpkg.com/ssri/-/ssri-5.3.0.tgz#ba3872c9c6d33a0704a7d71ff045e5ec48999d06" - integrity sha512-XRSIPqLij52MtgoQavH/x/dU1qVKtWUAAZeOHsR9c2Ddi4XerFy3mc1alf+dLJKl9EUIm/Ht+EowFkTUOA6GAQ== - dependencies: - safe-buffer "^5.1.1" - -ssri@^6.0.0: - version "6.0.1" - resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8" - integrity sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA== - dependencies: - figgy-pudding "^3.5.1" - -stack-trace@0.0.x: - version "0.0.10" - resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" - integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA= - -static-eval@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/static-eval/-/static-eval-2.0.0.tgz#0e821f8926847def7b4b50cda5d55c04a9b13864" - integrity sha512-6flshd3F1Gwm+Ksxq463LtFd1liC77N/PX1FVVc3OzL3hAmo2fwHFbuArkcfi7s9rTNsLEhcRmXGFZhlgy40uw== - dependencies: - escodegen "^1.8.1" - -static-extend@^0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" - integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY= - dependencies: - define-property "^0.2.5" - object-copy "^0.1.0" - -static-module@^2.2.0: - version "2.2.5" - resolved "https://registry.yarnpkg.com/static-module/-/static-module-2.2.5.tgz#bd40abceae33da6b7afb84a0e4329ff8852bfbbf" - integrity sha512-D8vv82E/Kpmz3TXHKG8PPsCPg+RAX6cbCOyvjM6x04qZtQ47EtJFVwRsdov3n5d6/6ynrOY9XB4JkaZwB2xoRQ== - dependencies: - concat-stream "~1.6.0" - convert-source-map "^1.5.1" - duplexer2 "~0.1.4" - escodegen "~1.9.0" - falafel "^2.1.0" - has "^1.0.1" - magic-string "^0.22.4" - merge-source-map "1.0.4" - object-inspect "~1.4.0" - quote-stream "~1.0.2" - readable-stream "~2.3.3" - shallow-copy "~0.0.1" - static-eval "^2.0.0" - through2 "~2.0.3" - -stats-webpack-plugin@0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/stats-webpack-plugin/-/stats-webpack-plugin-0.7.0.tgz#ccffe9b745de8bbb155571e063f8263fc0e2bc06" - integrity sha512-NT0YGhwuQ0EOX+uPhhUcI6/+1Sq/pMzNuSCBVT4GbFl/ac6I/JZefBcjlECNfAb1t3GOx5dEj1Z7x0cAxeeVLQ== - dependencies: - lodash "^4.17.4" - -"statuses@>= 1.3.1 < 2", "statuses@>= 1.4.0 < 2": - version "1.5.0" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" - integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= - -statuses@~1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e" - integrity sha1-+vUbnrdKrvOzrPStX2Gr8ky3uT4= - -statuses@~1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087" - integrity sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew== - -stdout-stream@^1.4.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/stdout-stream/-/stdout-stream-1.4.1.tgz#5ac174cdd5cd726104aa0c0b2bd83815d8d535de" - integrity sha512-j4emi03KXqJWcIeF8eIXkjMFN1Cmb8gUlDYGeBALLPo5qdyTfA9bOtl8m33lRoC+vFMkP3gl0WsDr6+gzxbbTA== - dependencies: - readable-stream "^2.0.1" - -stream-browserify@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db" - integrity sha1-ZiZu5fm9uZQKTkUUyvtDu3Hlyds= - dependencies: - inherits "~2.0.1" - readable-stream "^2.0.2" - -stream-each@^1.1.0: - version "1.2.3" - resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.3.tgz#ebe27a0c389b04fbcc233642952e10731afa9bae" - integrity sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw== - dependencies: - end-of-stream "^1.1.0" - stream-shift "^1.0.0" - -stream-http@^2.7.2: - version "2.8.3" - resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.8.3.tgz#b2d242469288a5a27ec4fe8933acf623de6514fc" - integrity sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw== - dependencies: - builtin-status-codes "^3.0.0" - inherits "^2.0.1" - readable-stream "^2.3.6" - to-arraybuffer "^1.0.0" - xtend "^4.0.0" - -stream-json@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/stream-json/-/stream-json-0.5.2.tgz#f4256c0ef1a905f2ef2d473706b4b3ff827653cf" - integrity sha512-/QlOLybrc6LkH/oVgXo1lJ8oemRIze/grdpfssLn1Pm8nZVhW4VMOqDDYGaORPodQFxiDEUquQJER5LiBu8Ehg== - dependencies: - parser-toolkit ">=0.0.3" - -stream-shift@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952" - integrity sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI= - -streamroller@0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-0.7.0.tgz#a1d1b7cf83d39afb0d63049a5acbf93493bdf64b" - integrity sha512-WREzfy0r0zUqp3lGO096wRuUp7ho1X6uo/7DJfTlEi0Iv/4gT7YHqXDjKC2ioVGBZtE8QzsQD9nx1nIuoZ57jQ== - dependencies: - date-format "^1.2.0" - debug "^3.1.0" - mkdirp "^0.5.1" - readable-stream "^2.3.0" - -strict-uri-encode@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" - integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= - -string-to-stream@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/string-to-stream/-/string-to-stream-1.1.1.tgz#aba78f73e70661b130ee3e1c0192be4fef6cb599" - integrity sha512-QySF2+3Rwq0SdO3s7BAp4x+c3qsClpPQ6abAmb0DGViiSBAkT5kL6JT2iyzEVP+T1SmzHrQD1TwlP9QAHCc+Sw== - dependencies: - inherits "^2.0.1" - readable-stream "^2.1.0" - -string-width@^1.0.1, string-width@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" - integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= - dependencies: - code-point-at "^1.0.0" - is-fullwidth-code-point "^1.0.0" - strip-ansi "^3.0.0" - -"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" - integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== - dependencies: - is-fullwidth-code-point "^2.0.0" - strip-ansi "^4.0.0" - -string_decoder@^1.0.0, string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - -string_decoder@~0.10.x: - version "0.10.31" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" - integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ= - -strip-ansi@^3.0.0, strip-ansi@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" - integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= - dependencies: - ansi-regex "^2.0.0" - -strip-ansi@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" - integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= - dependencies: - ansi-regex "^3.0.0" - -strip-bom@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e" - integrity sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4= - dependencies: - is-utf8 "^0.2.0" - -strip-bom@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" - integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= - -strip-eof@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" - integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= - -strip-indent@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2" - integrity sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI= - dependencies: - get-stdin "^4.0.1" - -strip-indent@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68" - integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g= - -strip-json-comments@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" - integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= - -style-loader@0.23.1: - version "0.23.1" - resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.23.1.tgz#cb9154606f3e771ab6c4ab637026a1049174d925" - integrity sha512-XK+uv9kWwhZMZ1y7mysB+zoihsEj4wneFWAS5qoiLwzW0WzSqMrrsIy+a3zkQJq0ipFtBpX5W3MqyRIBF/WFGg== - dependencies: - loader-utils "^1.1.0" - schema-utils "^1.0.0" - -stylus-loader@3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/stylus-loader/-/stylus-loader-3.0.2.tgz#27a706420b05a38e038e7cacb153578d450513c6" - integrity sha512-+VomPdZ6a0razP+zinir61yZgpw2NfljeSsdUF5kJuEzlo3khXhY19Fn6l8QQz1GRJGtMCo8nG5C04ePyV7SUA== - dependencies: - loader-utils "^1.0.2" - lodash.clonedeep "^4.5.0" - when "~3.6.x" - -stylus@0.54.5, stylus@^0.54.5: - version "0.54.5" - resolved "https://registry.yarnpkg.com/stylus/-/stylus-0.54.5.tgz#42b9560931ca7090ce8515a798ba9e6aa3d6dc79" - integrity sha1-QrlWCTHKcJDOhRWnmLqeaqPW3Hk= - dependencies: - css-parse "1.7.x" - debug "*" - glob "7.0.x" - mkdirp "0.5.x" - sax "0.5.x" - source-map "0.1.x" - -supports-color@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" - integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= - -supports-color@^3.1.0: - version "3.2.3" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.2.3.tgz#65ac0504b3954171d8a64946b2ae3cbb8a5f54f6" - integrity sha1-ZawFBLOVQXHYpklGsq48u4pfVPY= - dependencies: - has-flag "^1.0.0" - -supports-color@^5.1.0, supports-color@^5.3.0, supports-color@^5.4.0, supports-color@^5.5.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== - dependencies: - has-flag "^3.0.0" - -symbol-observable@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" - integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== - -tapable@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.0.0.tgz#cbb639d9002eed9c6b5975eb20598d7936f1f9f2" - integrity sha512-dQRhbNQkRnaqauC7WqSJ21EEksgT0fYZX2lqXzGkpo8JNig9zGZTYoMGvyI2nWmXlE2VSVXVDu7wLVGu/mQEsg== - -tapable@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.0.tgz#0d076a172e3d9ba088fd2272b2668fb8d194b78c" - integrity sha512-IlqtmLVaZA2qab8epUXbVWRn3aB1imbDMJtjB3nu4X0NqPkcY/JH9ZtCBWKHWPxs8Svi9tyo8w2dBoi07qZbBA== - -tar@^2.0.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.1.tgz#8e4d2a256c0e2185c6b18ad694aec968b83cb1d1" - integrity sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE= - dependencies: - block-stream "*" - fstream "^1.0.2" - inherits "2" - -tar@^4, tar@^4.4.4: - version "4.4.6" - resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.6.tgz#63110f09c00b4e60ac8bcfe1bf3c8660235fbc9b" - integrity sha512-tMkTnh9EdzxyfW+6GK6fCahagXsnYk6kE6S9Gr9pjVdys769+laCTbodXDhPAjzVtEBazRgP0gYqOjnk9dQzLg== - dependencies: - chownr "^1.0.1" - fs-minipass "^1.2.5" - minipass "^2.3.3" - minizlib "^1.1.0" - mkdirp "^0.5.0" - safe-buffer "^5.1.2" - yallist "^3.0.2" - -temp@^0.8.3: - version "0.8.3" - resolved "https://registry.yarnpkg.com/temp/-/temp-0.8.3.tgz#e0c6bc4d26b903124410e4fed81103014dfc1f59" - integrity sha1-4Ma8TSa5AxJEEOT+2BEDAU38H1k= - dependencies: - os-tmpdir "^1.0.0" - rimraf "~2.2.6" - -term-size@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69" - integrity sha1-RYuDiH8oj8Vtb/+/rSYuJmOO+mk= - dependencies: - execa "^0.7.0" - -terser-webpack-plugin@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.1.0.tgz#cf7c25a1eee25bf121f4a587bb9e004e3f80e528" - integrity sha512-61lV0DSxMAZ8AyZG7/A4a3UPlrbOBo8NIQ4tJzLPAdGOQ+yoNC7l5ijEow27lBAL2humer01KLS6bGIMYQxKoA== - dependencies: - cacache "^11.0.2" - find-cache-dir "^2.0.0" - schema-utils "^1.0.0" - serialize-javascript "^1.4.0" - source-map "^0.6.1" - terser "^3.8.1" - webpack-sources "^1.1.0" - worker-farm "^1.5.2" - -terser@^3.8.1: - version "3.8.2" - resolved "https://registry.yarnpkg.com/terser/-/terser-3.8.2.tgz#48b880f949f8d038aca4dfd00a37c53d96ecf9fb" - integrity sha512-FGSBXiBJe2TSXy6pWwXpY0YcEWEK35UKL64BBbxX3aHqM4Nj0RMqXvqBuoSGfyd80t8MKQ5JwYm5jRRGTSEFNg== - dependencies: - commander "~2.17.1" - source-map "~0.6.1" - source-map-support "~0.5.6" - -text-extensions@^1.0.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/text-extensions/-/text-extensions-1.8.0.tgz#6f343c62268843019b21a616a003557bdb952d2b" - integrity sha512-mVzjRxuWnDKs/qH1rbOJEVHLlSX9kty9lpi7lMvLgU9S74mQ8/Ozg9UPcKxShh0qG2NZ+NyPOPpcZU4C1Eld9A== - -text-hex@1.0.x: - version "1.0.0" - resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" - integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== - -through2@^2.0.0, through2@^2.0.2, through2@^2.0.3, through2@~2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.3.tgz#0004569b37c7c74ba39c43f3ced78d1ad94140be" - integrity sha1-AARWmzfHx0ujnEPzzteNGtlBQL4= - dependencies: - readable-stream "^2.1.5" - xtend "~4.0.1" - -through@2, "through@>=2.2.7 <3", through@X.X.X, through@^2.3.6: - version "2.3.8" - resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" - integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= - -thunky@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.0.2.tgz#a862e018e3fb1ea2ec3fce5d55605cf57f247371" - integrity sha1-qGLgGOP7HqLsP85dVWBc9X8kc3E= - -timed-out@^4.0.0, timed-out@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f" - integrity sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8= - -timers-browserify@^2.0.4: - version "2.0.10" - resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.10.tgz#1d28e3d2aadf1d5a5996c4e9f95601cd053480ae" - integrity sha512-YvC1SV1XdOUaL6gx5CoGroT3Gu49pK9+TZ38ErPldOWW4j49GI1HKs9DV+KGq/w6y+LZ72W1c8cKz2vzY+qpzg== - dependencies: - setimmediate "^1.0.4" - -tiny-inflate@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.2.tgz#93d9decffc8805bd57eae4310f0b745e9b6fb3a7" - integrity sha1-k9nez/yIBb1X6uQxDwt0Xptvs6c= - -tmp@0.0.30: - version "0.0.30" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.30.tgz#72419d4a8be7d6ce75148fd8b324e593a711c2ed" - integrity sha1-ckGdSovn1s51FI/YsyTlk6cRwu0= - dependencies: - os-tmpdir "~1.0.1" - -tmp@0.0.33, tmp@0.0.x, tmp@^0.0.33: - version "0.0.33" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" - integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== - dependencies: - os-tmpdir "~1.0.2" - -to-array@0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890" - integrity sha1-F+bBH3PdTz10zaek/zI46a2b+JA= - -to-arraybuffer@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" - integrity sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M= - -to-fast-properties@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47" - integrity sha1-uDVx+k2MJbguIxsG46MFXeTKGkc= - -to-fast-properties@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" - integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= - -to-object-path@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" - integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68= - dependencies: - kind-of "^3.0.2" - -to-regex-range@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" - integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg= - dependencies: - is-number "^3.0.0" - repeat-string "^1.6.1" - -to-regex@^3.0.1, to-regex@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" - integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw== - dependencies: - define-property "^2.0.2" - extend-shallow "^3.0.2" - regex-not "^1.0.2" - safe-regex "^1.1.0" - -tough-cookie@~2.3.3: - version "2.3.4" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.4.tgz#ec60cee38ac675063ffc97a5c18970578ee83655" - integrity sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA== - dependencies: - punycode "^1.4.1" - -tough-cookie@~2.4.3: - version "2.4.3" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" - integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ== - dependencies: - psl "^1.1.24" - punycode "^1.4.1" - -tree-kill@1.2.0, tree-kill@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.0.tgz#5846786237b4239014f05db156b643212d4c6f36" - integrity sha512-DlX6dR0lOIRDFxI0mjL9IYg6OTncLm/Zt+JiBhE5OlFcAR8yc9S7FFXU9so0oda47frdM/JFsk7UjNt9vscKcg== - -treeify@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/treeify/-/treeify-1.1.0.tgz#4e31c6a463accd0943879f30667c4fdaff411bb8" - integrity sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A== - -trim-newlines@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613" - integrity sha1-WIeWa7WCpFA6QetST301ARgVphM= - -trim-newlines@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-2.0.0.tgz#b403d0b91be50c331dfc4b82eeceb22c3de16d20" - integrity sha1-tAPQuRvlDDMd/EuC7s6yLD3hbSA= - -trim-off-newlines@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/trim-off-newlines/-/trim-off-newlines-1.0.1.tgz#9f9ba9d9efa8764c387698bcbfeb2c848f11adb3" - integrity sha1-n5up2e+odkw4dpi8v+sshI8RrbM= - -trim-right@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" - integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM= - -triple-beam@^1.2.0, triple-beam@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9" - integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw== - -"true-case-path@^1.0.2": - version "1.0.3" - resolved "https://registry.yarnpkg.com/true-case-path/-/true-case-path-1.0.3.tgz#f813b5a8c86b40da59606722b144e3225799f47d" - integrity sha512-m6s2OdQe5wgpFMC+pAJ+q9djG82O2jcHPOI6RNg1yy9rCYR+WD6Nbpl32fDpfC56nirdRy+opFa/Vk7HYhqaew== - dependencies: - glob "^7.1.2" - -ts-node@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-5.0.1.tgz#78e5d1cb3f704de1b641e43b76be2d4094f06f81" - integrity sha512-XK7QmDcNHVmZkVtkiwNDWiERRHPyU8nBqZB1+iv2UhOG0q3RQ9HsZ2CMqISlFbxjrYFGfG2mX7bW4dAyxBVzUw== - dependencies: - arrify "^1.0.0" - chalk "^2.3.0" - diff "^3.1.0" - make-error "^1.1.1" - minimist "^1.2.0" - mkdirp "^0.5.1" - source-map-support "^0.5.3" - yn "^2.0.0" - -tsickle@0.32.1, tsickle@>=0.27.3: - version "0.32.1" - resolved "https://registry.yarnpkg.com/tsickle/-/tsickle-0.32.1.tgz#f16e94ba80b32fc9ebe320dc94fbc2ca7f3521a5" - integrity sha512-JW9j+W0SaMSZGejIFZBk0AiPfnhljK3oLx5SaqxrJhjlvzFyPml5zqG1/PuScUj6yTe1muEqwk5CnDK0cOZmKw== - dependencies: - jasmine-diff "^0.1.3" - minimist "^1.2.0" - mkdirp "^0.5.1" - source-map "^0.6.0" - source-map-support "^0.5.0" - -tslib@^1.7.1, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0: - version "1.9.3" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" - integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ== - -tslint-no-circular-imports@^0.5.0: - version "0.5.2" - resolved "https://registry.yarnpkg.com/tslint-no-circular-imports/-/tslint-no-circular-imports-0.5.2.tgz#47ac9194ec531101ac19198d5144f7ac9f57d980" - integrity sha512-Uzu2NiVX7b/kAk9vPrLqUmoCkth0dXUUNOaqwfxpvYgsHfoH67cmj0IHOUMZ1bC0swksJjaTyq7acHe/gU8H4A== - -tslint-sonarts@^1.7.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/tslint-sonarts/-/tslint-sonarts-1.8.0.tgz#80f2e8addd418c3b8807fc0155dec33b5a81e8d1" - integrity sha512-tpijO5VR18e+Ny99uMNNov3Hw7diiYQ8KoJkezpHGw9hSFFrO5g2PhwdQQo7O9puhJKMIutLl9g+ICMgg+bh0w== - dependencies: - immutable "^3.8.2" - -tslint@^5.11.0: - version "5.11.0" - resolved "https://registry.yarnpkg.com/tslint/-/tslint-5.11.0.tgz#98f30c02eae3cde7006201e4c33cb08b48581eed" - integrity sha1-mPMMAurjzecAYgHkwzywi0hYHu0= - dependencies: - babel-code-frame "^6.22.0" - builtin-modules "^1.1.1" - chalk "^2.3.0" - commander "^2.12.1" - diff "^3.2.0" - glob "^7.1.1" - js-yaml "^3.7.0" - minimatch "^3.0.4" - resolve "^1.3.2" - semver "^5.3.0" - tslib "^1.8.0" - tsutils "^2.27.2" - -tsutils@2.20.0: - version "2.20.0" - resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-2.20.0.tgz#303394064bc80be8ee04e10b8609ae852e9312d3" - integrity sha512-qPOBy1/hwLdBxh/TNIpim5qL1WRMR0tgVGBB6shjnpw6/SuS5ZKYyXXxKDYsMsMtVdFOcL+XPGZVEuc+eCOo4A== - dependencies: - tslib "^1.8.1" - -tsutils@^2.27.2: - version "2.29.0" - resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-2.29.0.tgz#32b488501467acbedd4b85498673a0812aca0b99" - integrity sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA== - dependencies: - tslib "^1.8.1" - -tty-browserify@0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" - integrity sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY= - -tunnel-agent@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" - integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= - dependencies: - safe-buffer "^5.0.1" - -tweetnacl@^0.14.3, tweetnacl@~0.14.0: - version "0.14.5" - resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" - integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= - -type-check@~0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" - integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= - dependencies: - prelude-ls "~1.1.2" - -type-is@~1.6.15, type-is@~1.6.16: - version "1.6.16" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.16.tgz#f89ce341541c672b25ee7ae3c73dee3b2be50194" - integrity sha512-HRkVv/5qY2G6I8iab9cI7v1bOIdhm94dVjQCPFElW9W+3GeDOSHmy2EBYe4VTApuzolPcmgFTN3ftVJRKR2J9Q== - dependencies: - media-typer "0.3.0" - mime-types "~2.1.18" - -typedarray@^0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" - integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= - -typescript@3.1.3, typescript@~3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.1.3.tgz#01b70247a6d3c2467f70c45795ef5ea18ce191d5" - integrity sha512-+81MUSyX+BaSo+u2RbozuQk/UWx6hfG0a5gHu4ANEM4sU96XbuIyAB+rWBW1u70c6a5QuZfuYICn3s2UjuHUpA== - -uglify-es@^3.3.4: - version "3.3.9" - resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.3.9.tgz#0c1c4f0700bed8dbc124cdb304d2592ca203e677" - integrity sha512-r+MU0rfv4L/0eeW3xZrd16t4NZfK8Ld4SWVglYBb7ez5uXFWHuVRs6xCTrf1yirs9a4j4Y27nn7SRfO6v67XsQ== - dependencies: - commander "~2.13.0" - source-map "~0.6.1" - -uglify-js@^3.0.7, uglify-js@^3.1.4: - version "3.4.9" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.9.tgz#af02f180c1207d76432e473ed24a28f4a782bae3" - integrity sha512-8CJsbKOtEbnJsTyv6LE6m6ZKniqMiFWmm9sRbopbkGs3gMPPfd3Fh8iIA4Ykv5MgaTbqHr4BaoGLJLZNhsrW1Q== - dependencies: - commander "~2.17.1" - source-map "~0.6.1" - -uglifyjs-webpack-plugin@^1.2.4: - version "1.3.0" - resolved "https://registry.yarnpkg.com/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-1.3.0.tgz#75f548160858163a08643e086d5fefe18a5d67de" - integrity sha512-ovHIch0AMlxjD/97j9AYovZxG5wnHOPkL7T1GKochBADp/Zwc44pEWNqpKl1Loupp1WhFg7SlYmHZRUfdAacgw== - dependencies: - cacache "^10.0.4" - find-cache-dir "^1.0.0" - schema-utils "^0.4.5" - serialize-javascript "^1.4.0" - source-map "^0.6.1" - uglify-es "^3.3.4" - webpack-sources "^1.1.0" - worker-farm "^1.5.2" - -ultron@~1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c" - integrity sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og== - -"unicode-properties@github:quicktype/unicode-properties#dist": - version "1.1.0" - resolved "https://codeload.github.com/quicktype/unicode-properties/tar.gz/d5fddfea1ef9d05c6479a979e225476063e13f52" - dependencies: - brfs "^1.4.0" - unicode-trie "^0.3.0" - -unicode-trie@^0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/unicode-trie/-/unicode-trie-0.3.1.tgz#d671dddd89101a08bac37b6a5161010602052085" - integrity sha1-1nHd3YkQGgi6w3tqUWEBBgIFIIU= - dependencies: - pako "^0.2.5" - tiny-inflate "^1.0.0" - -union-value@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4" - integrity sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ= - dependencies: - arr-union "^3.1.0" - get-value "^2.0.6" - is-extendable "^0.1.1" - set-value "^0.4.3" - -unique-filename@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.0.tgz#d05f2fe4032560871f30e93cbe735eea201514f3" - integrity sha1-0F8v5AMlYIcfMOk8vnNe6iAVFPM= - dependencies: - unique-slug "^2.0.0" - -unique-slug@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.0.tgz#db6676e7c7cc0629878ff196097c78855ae9f4ab" - integrity sha1-22Z258fMBimHj/GWCXx4hVrp9Ks= - dependencies: - imurmurhash "^0.1.4" - -unique-string@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-1.0.0.tgz#9e1057cca851abb93398f8b33ae187b99caec11a" - integrity sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo= - dependencies: - crypto-random-string "^1.0.0" - -universalify@^0.1.0: - version "0.1.2" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" - integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== - -unpipe@1.0.0, unpipe@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= - -unset-value@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" - integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk= - dependencies: - has-value "^0.3.1" - isobject "^3.0.0" - -unzip-response@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97" - integrity sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c= - -upath@^1.0.5: - version "1.1.0" - resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.0.tgz#35256597e46a581db4793d0ce47fa9aebfc9fabd" - integrity sha512-bzpH/oBhoS/QI/YtbkqCg6VEiPYjSZtrHQM6/QnJS6OL9pKUFLqb3aFh4Scvwm45+7iAgiMkLhSbaZxUqmrprw== - -update-notifier@^2.3.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.5.0.tgz#d0744593e13f161e406acb1d9408b72cad08aff6" - integrity sha512-gwMdhgJHGuj/+wHJJs9e6PcCszpxR1b236igrOkUofGhqJuG+amlIKwApH1IW1WWl7ovZxsX49lMBWLxSdm5Dw== - dependencies: - boxen "^1.2.1" - chalk "^2.0.1" - configstore "^3.0.0" - import-lazy "^2.1.0" - is-ci "^1.0.10" - is-installed-globally "^0.1.0" - is-npm "^1.0.0" - latest-version "^3.0.0" - semver-diff "^2.0.0" - xdg-basedir "^3.0.0" - -uri-js@^4.2.2: - version "4.2.2" - resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" - integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ== - dependencies: - punycode "^2.1.0" - -urijs@^1.19.1: - version "1.19.1" - resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.1.tgz#5b0ff530c0cbde8386f6342235ba5ca6e995d25a" - integrity sha512-xVrGVi94ueCJNrBSTjWqjvtgvl3cyOTThp2zaMaFNGp3F542TR6sM3f2o8RqZl+AwteClSVmoCyt0ka4RjQOQg== - -urix@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" - integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= - -url-join@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/url-join/-/url-join-4.0.0.tgz#4d3340e807d3773bda9991f8305acdcc2a665d2a" - integrity sha1-TTNA6AfTdzvamZH4MFrNzCpmXSo= - -url-parse-lax@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73" - integrity sha1-evjzA2Rem9eaJy56FKxovAYJ2nM= - dependencies: - prepend-http "^1.0.1" - -url-parse-lax@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c" - integrity sha1-FrXK/Afb42dsGxmZF3gj1lA6yww= - dependencies: - prepend-http "^2.0.0" - -url-parse@^1.1.8, url-parse@^1.4.3: - version "1.4.3" - resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.3.tgz#bfaee455c889023219d757e045fa6a684ec36c15" - integrity sha512-rh+KuAW36YKo0vClhQzLLveoj8FwPJNu65xLb7Mrt+eZht0IPT0IXgSv8gcMegZ6NvjJUALf6Mf25POlMwD1Fw== - dependencies: - querystringify "^2.0.0" - requires-port "^1.0.0" - -url-to-options@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9" - integrity sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k= - -url@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" - integrity sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE= - dependencies: - punycode "1.3.2" - querystring "0.2.0" - -use@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" - integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== - -useragent@2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/useragent/-/useragent-2.2.1.tgz#cf593ef4f2d175875e8bb658ea92e18a4fd06d8e" - integrity sha1-z1k+9PLRdYdei7ZY6pLhik/QbY4= - dependencies: - lru-cache "2.2.x" - tmp "0.0.x" - -util-deprecate@~1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= - -util-extend@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/util-extend/-/util-extend-1.0.3.tgz#a7c216d267545169637b3b6edc6ca9119e2ff93f" - integrity sha1-p8IW0mdUUWljeztu3GypEZ4v+T8= - -util@0.10.3: - version "0.10.3" - resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" - integrity sha1-evsa/lCAUkZInj23/g7TeTNqwPk= - dependencies: - inherits "2.0.1" - -util@^0.10.3: - version "0.10.4" - resolved "https://registry.yarnpkg.com/util/-/util-0.10.4.tgz#3aa0125bfe668a4672de58857d3ace27ecb76901" - integrity sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A== - dependencies: - inherits "2.0.3" - -utils-merge@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" - integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= - -uuid@^3.0.1, uuid@^3.1.0, uuid@^3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" - integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== - -validate-npm-package-license@^3.0.1: - version "3.0.4" - resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" - integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== - dependencies: - spdx-correct "^3.0.0" - spdx-expression-parse "^3.0.0" - -validate-npm-package-name@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz#5fa912d81eb7d0c74afc140de7317f0ca7df437e" - integrity sha1-X6kS2B630MdK/BQN5zF/DKffQ34= - dependencies: - builtins "^1.0.3" - -vary@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= - -verror@1.10.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" - integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= - dependencies: - assert-plus "^1.0.0" - core-util-is "1.0.2" - extsprintf "^1.2.0" - -vlq@^0.2.2: - version "0.2.3" - resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26" - integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow== - -vm-browserify@0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-0.0.4.tgz#5d7ea45bbef9e4a6ff65f95438e0a87c357d5a73" - integrity sha1-XX6kW7755Kb/ZflUOOCofDV9WnM= - dependencies: - indexof "0.0.1" - -void-elements@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" - integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w= - -watchpack@^1.5.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00" - integrity sha512-i6dHe3EyLjMmDlU1/bGQpEw25XSjkJULPuAVKCbNRefQVq48yXKUpwg538F7AZTf9kyr57zj++pQFltUa5H7yA== - dependencies: - chokidar "^2.0.2" - graceful-fs "^4.1.2" - neo-async "^2.5.0" - -wbuf@^1.1.0, wbuf@^1.7.2: - version "1.7.3" - resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df" - integrity sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA== - dependencies: - minimalistic-assert "^1.0.0" - -webdriver-js-extender@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/webdriver-js-extender/-/webdriver-js-extender-2.1.0.tgz#57d7a93c00db4cc8d556e4d3db4b5db0a80c3bb7" - integrity sha512-lcUKrjbBfCK6MNsh7xaY2UAUmZwe+/ib03AjVOpFobX4O7+83BUveSrLfU0Qsyb1DaKJdQRbuU+kM9aZ6QUhiQ== - dependencies: - "@types/selenium-webdriver" "^3.0.0" - selenium-webdriver "^3.0.1" - -webdriver-manager@^12.0.6: - version "12.1.0" - resolved "https://registry.yarnpkg.com/webdriver-manager/-/webdriver-manager-12.1.0.tgz#f6601e52de5f0c97fc7024c889eeb2416f2f1d9d" - integrity sha512-oEc5fmkpz6Yh6udhwir5m0eN5mgRPq9P/NU5YWuT3Up5slt6Zz+znhLU7q4+8rwCZz/Qq3Fgpr/4oao7NPCm2A== - dependencies: - adm-zip "^0.4.9" - chalk "^1.1.1" - del "^2.2.0" - glob "^7.0.3" - ini "^1.3.4" - minimist "^1.2.0" - q "^1.4.1" - request "^2.87.0" - rimraf "^2.5.2" - semver "^5.3.0" - xml2js "^0.4.17" - -webpack-core@^0.6.8: - version "0.6.9" - resolved "https://registry.yarnpkg.com/webpack-core/-/webpack-core-0.6.9.tgz#fc571588c8558da77be9efb6debdc5a3b172bdc2" - integrity sha1-/FcViMhVjad76e+23r3Fo7FyvcI= - dependencies: - source-list-map "~0.1.7" - source-map "~0.4.1" - -webpack-dev-middleware@3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.2.0.tgz#a20ceef194873710052da678f3c6ee0aeed92552" - integrity sha512-YJLMF/96TpKXaEQwaLEo+Z4NDK8aV133ROF6xp9pe3gQoS7sxfpXh4Rv9eC+8vCvWfmDjRQaMSlRPbO+9G6jgA== - dependencies: - loud-rejection "^1.6.0" - memory-fs "~0.4.1" - mime "^2.3.1" - path-is-absolute "^1.0.0" - range-parser "^1.0.3" - url-join "^4.0.0" - webpack-log "^2.0.0" - -webpack-dev-middleware@3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.4.0.tgz#1132fecc9026fd90f0ecedac5cbff75d1fb45890" - integrity sha512-Q9Iyc0X9dP9bAsYskAVJ/hmIZZQwf/3Sy4xCAZgL5cUkjZmUZLt4l5HpbST/Pdgjn3u6pE7u5OdGd1apgzRujA== - dependencies: - memory-fs "~0.4.1" - mime "^2.3.1" - range-parser "^1.0.3" - webpack-log "^2.0.0" - -webpack-dev-server@3.1.10: - version "3.1.10" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.1.10.tgz#507411bee727ee8d2fdffdc621b66a64ab3dea2b" - integrity sha512-RqOAVjfqZJtQcB0LmrzJ5y4Jp78lv9CK0MZ1YJDTaTmedMZ9PU9FLMQNrMCfVu8hHzaVLVOJKBlGEHMN10z+ww== - dependencies: - ansi-html "0.0.7" - bonjour "^3.5.0" - chokidar "^2.0.0" - compression "^1.5.2" - connect-history-api-fallback "^1.3.0" - debug "^3.1.0" - del "^3.0.0" - express "^4.16.2" - html-entities "^1.2.0" - http-proxy-middleware "~0.18.0" - import-local "^2.0.0" - internal-ip "^3.0.1" - ip "^1.1.5" - killable "^1.0.0" - loglevel "^1.4.1" - opn "^5.1.0" - portfinder "^1.0.9" - schema-utils "^1.0.0" - selfsigned "^1.9.1" - serve-index "^1.7.2" - sockjs "0.3.19" - sockjs-client "1.3.0" - spdy "^3.4.1" - strip-ansi "^3.0.0" - supports-color "^5.1.0" - webpack-dev-middleware "3.4.0" - webpack-log "^2.0.0" - yargs "12.0.2" - -webpack-dev-server@^3.1.4: - version "3.1.8" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.1.8.tgz#eb7a95945d1108170f902604fb3b939533d9daeb" - integrity sha512-c+tcJtDqnPdxCAzEEZKdIPmg3i5i7cAHe+B+0xFNK0BlCc2HF/unYccbU7xTgfGc5xxhCztCQzFmsqim+KhI+A== - dependencies: - ansi-html "0.0.7" - bonjour "^3.5.0" - chokidar "^2.0.0" - compression "^1.5.2" - connect-history-api-fallback "^1.3.0" - debug "^3.1.0" - del "^3.0.0" - express "^4.16.2" - html-entities "^1.2.0" - http-proxy-middleware "~0.18.0" - import-local "^2.0.0" - internal-ip "^3.0.1" - ip "^1.1.5" - killable "^1.0.0" - loglevel "^1.4.1" - opn "^5.1.0" - portfinder "^1.0.9" - schema-utils "^1.0.0" - selfsigned "^1.9.1" - serve-index "^1.7.2" - sockjs "0.3.19" - sockjs-client "1.1.5" - spdy "^3.4.1" - strip-ansi "^3.0.0" - supports-color "^5.1.0" - webpack-dev-middleware "3.2.0" - webpack-log "^2.0.0" - yargs "12.0.2" - -webpack-log@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/webpack-log/-/webpack-log-2.0.0.tgz#5b7928e0637593f119d32f6227c1e0ac31e1b47f" - integrity sha512-cX8G2vR/85UYG59FgkoMamwHUIkSSlV3bBMRsbxVXVUk2j6NleCKjQ/WE9eYg9WY4w25O9w8wKP4rzNZFmUcUg== - dependencies: - ansi-colors "^3.0.0" - uuid "^3.3.2" - -webpack-merge@4.1.4: - version "4.1.4" - resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-4.1.4.tgz#0fde38eabf2d5fd85251c24a5a8c48f8a3f4eb7b" - integrity sha512-TmSe1HZKeOPey3oy1Ov2iS3guIZjWvMT2BBJDzzT5jScHTjVC3mpjJofgueEzaEd6ibhxRDD6MIblDr8tzh8iQ== - dependencies: - lodash "^4.17.5" - -webpack-sources@1.2.0, webpack-sources@^1.1.0, webpack-sources@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.2.0.tgz#18181e0d013fce096faf6f8e6d41eeffffdceac2" - integrity sha512-9BZwxR85dNsjWz3blyxdOhTgtnQvv3OEs5xofI0wPYTwu5kaWxS08UuD1oI7WLBLpRO+ylf0ofnXLXWmGb2WMw== - dependencies: - source-list-map "^2.0.0" - source-map "~0.6.1" - -webpack-sources@1.3.0, webpack-sources@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.3.0.tgz#2a28dcb9f1f45fe960d8f1493252b5ee6530fa85" - integrity sha512-OiVgSrbGu7NEnEvQJJgdSFPl2qWKkWq5lHMhgiToIiN9w34EBnjYzSYs+VbL5KoYiLNtFFa7BZIKxRED3I32pA== - dependencies: - source-list-map "^2.0.0" - source-map "~0.6.1" - -webpack-subresource-integrity@1.1.0-rc.6: - version "1.1.0-rc.6" - resolved "https://registry.yarnpkg.com/webpack-subresource-integrity/-/webpack-subresource-integrity-1.1.0-rc.6.tgz#37f6f1264e1eb378e41465a98da80fad76ab8886" - integrity sha512-Az7y8xTniNhaA0620AV1KPwWOqawurVVDzQSpPAeR5RwNbL91GoBSJAAo9cfd+GiFHwsS5bbHepBw1e6Hzxy4w== - dependencies: - webpack-core "^0.6.8" - -webpack@4.22.0, webpack@^4.0.0, webpack@^4.6.0: - version "4.22.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.22.0.tgz#b168111e2e7b05f5169ac484e79e62093ec39e0d" - integrity sha512-2+3EYFqyhPl12buLQ42QPHEEh8BHn3P9ipRvGRHhdfKJ1u9svhZ3QjhIoEdL5SeIhL5gfOZVbBnartYEabkEsg== - dependencies: - "@webassemblyjs/ast" "1.7.8" - "@webassemblyjs/helper-module-context" "1.7.8" - "@webassemblyjs/wasm-edit" "1.7.8" - "@webassemblyjs/wasm-parser" "1.7.8" - acorn "^5.6.2" - acorn-dynamic-import "^3.0.0" - ajv "^6.1.0" - ajv-keywords "^3.1.0" - chrome-trace-event "^1.0.0" - enhanced-resolve "^4.1.0" - eslint-scope "^4.0.0" - json-parse-better-errors "^1.0.2" - loader-runner "^2.3.0" - loader-utils "^1.1.0" - memory-fs "~0.4.1" - micromatch "^3.1.8" - mkdirp "~0.5.0" - neo-async "^2.5.0" - node-libs-browser "^2.0.0" - schema-utils "^0.4.4" - tapable "^1.1.0" - uglifyjs-webpack-plugin "^1.2.4" - watchpack "^1.5.0" - webpack-sources "^1.3.0" - -websocket-driver@>=0.5.1: - version "0.7.0" - resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.0.tgz#0caf9d2d755d93aee049d4bdd0d3fe2cca2a24eb" - integrity sha1-DK+dLXVdk67gSdS90NP+LMoqJOs= - dependencies: - http-parser-js ">=0.4.0" - websocket-extensions ">=0.1.1" - -websocket-extensions@>=0.1.1: - version "0.1.3" - resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.3.tgz#5d2ff22977003ec687a4b87073dfbbac146ccf29" - integrity sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg== - -when@~3.6.x: - version "3.6.4" - resolved "https://registry.yarnpkg.com/when/-/when-3.6.4.tgz#473b517ec159e2b85005497a13983f095412e34e" - integrity sha1-RztRfsFZ4rhQBUl6E5g/CVQS404= - -which-module@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f" - integrity sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8= - -which-module@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" - integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= - -which@1, which@^1.1.1, which@^1.2.1, which@^1.2.9: - version "1.3.1" - resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" - integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== - dependencies: - isexe "^2.0.0" - -wide-align@^1.1.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" - integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== - dependencies: - string-width "^1.0.2 || 2" - -widest-line@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.0.tgz#0142a4e8a243f8882c0233aa0e0281aa76152273" - integrity sha1-AUKk6KJD+IgsAjOqDgKBqnYVInM= - dependencies: - string-width "^2.1.1" - -window-size@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.4.tgz#f8e1aa1ee5a53ec5bf151ffa09742a6ad7697876" - integrity sha1-+OGqHuWlPsW/FR/6CXQqatdpeHY= - -winston-transport@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.2.0.tgz#a20be89edf2ea2ca39ba25f3e50344d73e6520e5" - integrity sha512-0R1bvFqxSlK/ZKTH86nymOuKv/cT1PQBMuDdA7k7f0S9fM44dNH6bXnuxwXPrN8lefJgtZq08BKdyZ0DZIy/rg== - dependencies: - readable-stream "^2.3.6" - triple-beam "^1.2.0" - -winston@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/winston/-/winston-3.1.0.tgz#80724376aef164e024f316100d5b178d78ac5331" - integrity sha512-FsQfEE+8YIEeuZEYhHDk5cILo1HOcWkGwvoidLrDgPog0r4bser1lEIOco2dN9zpDJ1M88hfDgZvxe5z4xNcwg== - dependencies: - async "^2.6.0" - diagnostics "^1.1.1" - is-stream "^1.1.0" - logform "^1.9.1" - one-time "0.0.4" - readable-stream "^2.3.6" - stack-trace "0.0.x" - triple-beam "^1.3.0" - winston-transport "^4.2.0" - -wordwrap@^1.0.0, wordwrap@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" - integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= - -wordwrap@~0.0.2: - version "0.0.3" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107" - integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc= - -worker-farm@^1.5.2: - version "1.6.0" - resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.6.0.tgz#aecc405976fab5a95526180846f0dba288f3a4a0" - integrity sha512-6w+3tHbM87WnSWnENBUvA2pxJPLhQUg5LKwUQHq3r+XPhIM+Gh2R5ycbwPCyuGbNg+lPgdcnQUhuC02kJCvffQ== - dependencies: - errno "~0.1.7" - -wrap-ansi@^2.0.0: - version "2.1.0" - resolved "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" - integrity sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU= - dependencies: - string-width "^1.0.1" - strip-ansi "^3.0.1" - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= - -write-file-atomic@^2.0.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.3.0.tgz#1ff61575c2e2a4e8e510d6fa4e243cce183999ab" - integrity sha512-xuPeK4OdjWqtfi59ylvVL0Yn35SF3zgcAcv7rBPFHVaEapaDr4GdGgm3j7ckTwH9wHL7fGmgfAnb0+THrHb8tA== - dependencies: - graceful-fs "^4.1.11" - imurmurhash "^0.1.4" - signal-exit "^3.0.2" - -ws@~3.3.1: - version "3.3.3" - resolved "https://registry.yarnpkg.com/ws/-/ws-3.3.3.tgz#f1cf84fe2d5e901ebce94efaece785f187a228f2" - integrity sha512-nnWLa/NwZSt4KQJu51MYlCcSQ5g7INpOrOMt4XV8j4dqTXdmlUmSHQ8/oLC069ckre0fRsgfvsKwbTdtKLCDkA== - dependencies: - async-limiter "~1.0.0" - safe-buffer "~5.1.0" - ultron "~1.1.0" - -xdg-basedir@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4" - integrity sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ= - -xhr2@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/xhr2/-/xhr2-0.1.4.tgz#7f87658847716db5026323812f818cadab387a5f" - integrity sha1-f4dliEdxbbUCYyOBL4GMras4el8= - -xml2js@^0.4.17: - version "0.4.19" - resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7" - integrity sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q== - dependencies: - sax ">=0.6.0" - xmlbuilder "~9.0.1" - -xmlbuilder@~9.0.1: - version "9.0.7" - resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d" - integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0= - -xmlhttprequest-ssl@~1.5.4: - version "1.5.5" - resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e" - integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4= - -xregexp@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-4.0.0.tgz#e698189de49dd2a18cc5687b05e17c8e43943020" - integrity sha512-PHyM+sQouu7xspQQwELlGwwd05mXUFqwFYfqPO0cC7x4fxyHnnuetmQr6CjJiafIDoH4MogHb9dOoJzR/Y4rFg== - -xtend@^4.0.0, xtend@~4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" - integrity sha1-pcbVMr5lbiPbgg77lDofBJmNY68= - -xxhashjs@^0.2.1: - version "0.2.2" - resolved "https://registry.yarnpkg.com/xxhashjs/-/xxhashjs-0.2.2.tgz#8a6251567621a1c46a5ae204da0249c7f8caa9d8" - integrity sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw== - dependencies: - cuint "^0.2.2" - -y18n@^3.2.0, y18n@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" - integrity sha1-bRX7qITAhnnA136I53WegR4H+kE= - -"y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" - integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== - -yallist@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" - integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= - -yallist@^3.0.0, yallist@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.2.tgz#8452b4bb7e83c7c188d8041c1a837c773d6d8bb9" - integrity sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k= - -yargs-parser@^10.1.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-10.1.0.tgz#7202265b89f7e9e9f2e5765e0fe735a905edbaa8" - integrity sha512-VCIyR1wJoEBZUqk5PA+oOBF6ypbwh5aNB3I50guxAL/quggdfs4TtNHQrSazFA3fYZ+tEqfs0zIGlv0c/rgjbQ== - dependencies: - camelcase "^4.1.0" - -yargs-parser@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-5.0.0.tgz#275ecf0d7ffe05c77e64e7c86e4cd94bf0e1228a" - integrity sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo= - dependencies: - camelcase "^3.0.0" - -yargs-parser@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-7.0.0.tgz#8d0ac42f16ea55debd332caf4c4038b3e3f5dfd9" - integrity sha1-jQrELxbqVd69MyyvTEA4s+P139k= - dependencies: - camelcase "^4.1.0" - -yargs@12.0.2: - version "12.0.2" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.2.tgz#fe58234369392af33ecbef53819171eff0f5aadc" - integrity sha512-e7SkEx6N6SIZ5c5H22RTZae61qtn3PYUE8JYbBFlK9sYmh3DMQ6E5ygtaG/2BW0JZi4WGgTR2IV5ChqlqrDGVQ== - dependencies: - cliui "^4.0.0" - decamelize "^2.0.0" - find-up "^3.0.0" - get-caller-file "^1.0.1" - os-locale "^3.0.0" - require-directory "^2.1.1" - require-main-filename "^1.0.1" - set-blocking "^2.0.0" - string-width "^2.0.0" - which-module "^2.0.0" - y18n "^3.2.1 || ^4.0.0" - yargs-parser "^10.1.0" - -yargs@9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-9.0.1.tgz#52acc23feecac34042078ee78c0c007f5085db4c" - integrity sha1-UqzCP+7Kw0BCB47njAwAf1CF20w= - dependencies: - camelcase "^4.1.0" - cliui "^3.2.0" - decamelize "^1.1.1" - get-caller-file "^1.0.1" - os-locale "^2.0.0" - read-pkg-up "^2.0.0" - require-directory "^2.1.1" - require-main-filename "^1.0.1" - set-blocking "^2.0.0" - string-width "^2.0.0" - which-module "^2.0.0" - y18n "^3.2.1" - yargs-parser "^7.0.0" - -yargs@^3.10.0: - version "3.32.0" - resolved "http://registry.npmjs.org/yargs/-/yargs-3.32.0.tgz#03088e9ebf9e756b69751611d2a5ef591482c995" - integrity sha1-AwiOnr+edWtpdRYR0qXvWRSCyZU= - dependencies: - camelcase "^2.0.1" - cliui "^3.0.3" - decamelize "^1.1.1" - os-locale "^1.4.0" - string-width "^1.0.1" - window-size "^0.1.4" - y18n "^3.2.0" - -yargs@^7.0.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-7.1.0.tgz#6ba318eb16961727f5d284f8ea003e8d6154d0c8" - integrity sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg= - dependencies: - camelcase "^3.0.0" - cliui "^3.2.0" - decamelize "^1.1.1" - get-caller-file "^1.0.1" - os-locale "^1.4.0" - read-pkg-up "^1.0.1" - require-directory "^2.1.1" - require-main-filename "^1.0.1" - set-blocking "^2.0.0" - string-width "^1.0.2" - which-module "^1.0.0" - y18n "^3.2.1" - yargs-parser "^5.0.0" - -yarn@^1.10.1: - version "1.10.1" - resolved "https://registry.yarnpkg.com/yarn/-/yarn-1.10.1.tgz#b792ba28f050ae94cd7e719dbca80639d70da76f" - integrity sha512-EH0H1fyfFxkE4UpG4b+VCaVY4I488I2EyQNmGjGGKmvsSIb0G3b+1IcfGYPI2gqa1du43g4ZA3jH8fXlq6oVxg== - -yeast@0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419" - integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk= - -yn@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/yn/-/yn-2.0.0.tgz#e5adabc8acf408f6385fc76495684c88e6af689a" - integrity sha1-5a2ryKz0CPY4X8dklWhMiOavaJo= - -zone.js@^0.8.19: - version "0.8.26" - resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.8.26.tgz#7bdd72f7668c5a7ad6b118148b4ea39c59d08d2d" - integrity sha512-W9Nj+UmBJG251wkCacIkETgra4QgBo/vgoEkb4a2uoLzpQG7qF9nzwoLXWU5xj3Fg2mxGvEDh47mg24vXccYjA==